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:
@@ -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) =>
|
||||
|
||||
Reference in New Issue
Block a user