Cache notes in one place, and serialise a project's writes

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
This commit is contained in:
2026-09-01 13:47:01 -07:00
co-authored by Claude Opus 5
parent be47c5edfd
commit 5c47656444
5 changed files with 613 additions and 99 deletions
+47 -1
View File
@@ -1,5 +1,12 @@
import { create } from "zustand";
import type { Project, TerminalSession, AppSettings, UpdateInfo, ImageUpdateInfo } from "../lib/types";
import type {
Project,
TerminalSession,
AppSettings,
UpdateInfo,
ImageUpdateInfo,
Note,
} from "../lib/types";
const SIDEBAR_COLLAPSED_KEY = "triple-c.sidebar.collapsed";
@@ -146,6 +153,29 @@ interface AppState {
/** Nudge the active tab left/right — the keyboard route to the same thing. */
moveActiveTab: (delta: number) => void;
// Per-project notes, cached from the backend.
//
// Rust is the source of truth and this is a cache — but it has to be *one*
// cache. Notes are shown by two surfaces at once (the Project Home sub-tab
// and the dock, which resolves to the same project), and a hook-local
// `useState` in each gave them independent copies: an edit made in the dock
// was invisible to the tab, and the tab's next blur wrote its stale record
// back over it with no error and no indicator. Keyed by project id so a
// response that lands after the user has moved on updates the project it
// belongs to instead of whichever one is on screen.
//
// This is also the boundary a detached notes window would need: swap the
// transport for a `notes-changed` event and both windows feed the same slice.
notesByProject: Record<string, Note[]>;
/**
* Projects with a `list_notes` in flight, so two panels mounting for the
* same project make one read rather than two, and so a panel whose project
* has never been read can tell "loading" from "no notes".
*/
notesLoading: Record<string, boolean>;
setProjectNotes: (projectId: string, notes: Note[]) => void;
setNotesLoading: (projectId: string, loading: boolean) => void;
// Inline container progress, replacing the blocking progress modal.
containerProgress: Record<string, string>;
setContainerProgress: (projectId: string, message: string | null) => void;
@@ -395,6 +425,22 @@ export const useAppState = create<AppState>((set) => ({
return { tabOrder };
}),
// Notes
notesByProject: {},
notesLoading: {},
setProjectNotes: (projectId, notes) =>
set((state) => ({
notesByProject: { ...state.notesByProject, [projectId]: notes },
})),
setNotesLoading: (projectId, loading) =>
set((state) => {
if ((state.notesLoading[projectId] ?? false) === loading) return {};
const next = { ...state.notesLoading };
if (loading) next[projectId] = true;
else delete next[projectId];
return { notesLoading: next };
}),
// Container progress
containerProgress: {},
setContainerProgress: (projectId, message) =>