Add a per-project Notes tab with a send-to-agent action #48

Merged
jknapp merged 20 commits from feat/project-notes into main 2026-09-02 20:42:07 +00:00
2 changed files with 88 additions and 9 deletions
Showing only changes of commit b6ba6deb09 - Show all commits
+70
View File
@@ -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);
});
}); });
+18 -9
View File
@@ -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;
}, []); }, []);