One gesture puts two requests in flight. With the tab already loaded, clicking
the dock toggle while the textarea has focus fires `blur` -> `saveNote` and the
dock's mount -> `list_notes` in the same tick. The save finishes and its re-read
writes the post-save list; the mount's read -- issued earlier, still out -- then
lands its pre-save snapshot on top, and both panels show stale text until
something else refreshes.
`mutationChains` could not have caught this: it orders a project's writes
against each other and the mount load is a read outside it. Putting the read on
the chain would work, but it buys correctness with latency the user feels -- a
panel mount waiting behind `save_note`'s double-fsync write -- and leaves a
"mutation chain" holding reads.
The two requests are not competing for a resource; the loser's result is simply
older. So every write into `notesByProject[p]` now claims a per-project sequence
when the request behind it is issued, and `commitNotes` drops one whose sequence
predates what is already cached. Reads take their sequence at issue time, since
being ordered by resolution is the bug. Local patches -- the filter behind a
confirmed delete, the prepend behind a failed re-read -- take a fresh one at
commit time, because they are authoritative then rather than derived from an
earlier read, and anything still in flight behind them is genuinely stale. A
failed read commits under its *own* sequence, not a fresh one, so its empty list
cannot beat a later read that has the real answer.
`isCurrent()` stays, and is not folded in. It guards `setSaveState`, not the
cache: it asks whether this *panel* is still showing the project a save was made
for, which a per-project counter cannot answer -- two panels on one project share
every sequence value. Ordering and panel identity are two questions.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HjL1E2JFNctUqCYotUwqqb
Implements the design spec's §2 — "notes cached in zustand keyed by
project id" — which the plan substituted with a hook-local `useState`.
Sharing the `NotesPanel` *component* between the Project Home sub-tab and
the dock did not share the *cache*. Both resolve to the same project, so
two panels mount two `useNotes(P)`, each with its own list. Edit a note
in the dock and blur; the tab's copy is still pre-edit, and the tab's
next blur commits `{...staleRecord, title, body}` — the dock's edit gone
from disk with no error and no indicator. That is the feature's own
primary workflow: take notes in the dock while the agent runs, which is
the reason the dock exists, then go back to the tab.
`notesByProject` plus a per-project in-flight flag now hold the list.
Both surfaces render from one array; two panels mounting for one project
make one read; and because the write is keyed by project, a response
that lands after the user has moved on updates the project it belongs to
rather than whichever is on screen. This is also the boundary §8 says a
detached notes window needs.
Three more bugs in the same code, fixed with it:
- Delete-after-edit could resurrect the note. Clicking Delete with the
textarea focused fires blur first, so `save_note` and `delete_note` go
out back to back; Rust's `write_lock` stops them interleaving but does
not order them, and a delete that wins the lock is undone by the
upsert behind it. A project's mutations now go through one promise
chain, module-scoped for the reason `useTerminal`'s input queue is.
- An unsaved draft vanished when any other note was saved, because the
re-read replaced the list with the backend's. "New note" now persists,
so the backend owns the row from the start — chosen over merging local
drafts because a local-only row in a *shared* cache would exist in the
panel that made it and nowhere else.
- The save outcome was reported for the wrong project after a switch:
the guard covered only the list replacement, so the new project's
SaveIndicator flashed "Saved ✓" for the old project's write. The
indicator now resets on a project change and reports only its own.
`NotesPanel` also re-seeds its draft when the *stored* text of the note
it has selected changes, so an edit made in the other surface reaches
the editor and not only the list. It never overwrites something
half-typed; that still blurs into a last-writer-wins save, as any
blur-commit editor does.
NotesPanel.shared.test.tsx is the configuration none of the existing
tests had: two panels, one project, the real hook. Four of its six
assertions fail against the previous implementation.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HjL1E2JFNctUqCYotUwqqb
When a save is in flight for project A and the user switches to project B before it resolves, the stale closure still has projectId=A. When A's save resolves, the post-save re-read of listNotes(projectId) runs with the stale closed-over projectId, and setNotes(reloaded) overwrites B's displayed notes with A's list—the same cross-project contamination class as Finding 2 but reintroduced through the fix itself.
Fix: Add a currentProjectId ref updated on every render, and guard both saveNote and deleteNote callbacks with a check before replacing/filtering the whole list. If the project changed while the async operation was in flight, bail out of the state update but still report success (the operation itself succeeded on the backend; only the stale list update is skipped).
Added test: a save in flight for one project, a switch to another, then the first save resolving—asserts the second project's notes are still displayed.
- Finding 1 (saveNote): After a successful save, re-read the canonical list from the backend instead of patching in place. A successful save stamps a new updated_at, and the backend sorts by updated_at descending, so the record's position has changed and positional patching would disagree with what a reload would show. If the re-read fails, keep the save reported as successful and leave the existing list alone.
- Finding 2 (stale notes): Clear notes on projectId change (not only when empty) and on load failure. Previously, switching from project A to project B would leave A's notes on screen until B's fetch resolved, and if a user edited one, A's note would be written into B's notes file—cross-project data corruption. If a load fails, A's notes stay visible under B indefinitely.
- Added four new tests covering these scenarios: projectId change clears old notes, failed load leaves no stale notes, saving a new note ends with the backend's list, and saves re-read the list rather than patching.