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 57 additions and 2 deletions
Showing only changes of commit a1f4eee9a3 - Show all commits
+43
View File
@@ -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");
});
}); });
+12
View File
@@ -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) });