diff --git a/app/src/components/notes/NoteEditor.tsx b/app/src/components/notes/NoteEditor.tsx
new file mode 100644
index 0000000..fa230db
--- /dev/null
+++ b/app/src/components/notes/NoteEditor.tsx
@@ -0,0 +1,63 @@
+import SendToAgentButton from "./SendToAgentButton";
+import Button from "../ui/Button";
+
+interface Props {
+ projectId: string;
+ title: string;
+ body: string;
+ onTitleChange: (value: string) => void;
+ onBodyChange: (value: string) => void;
+ onCommit: () => void;
+ onDelete: () => void;
+}
+
+/**
+ * Title and body, saved when a field loses focus.
+ *
+ * Plain text on purpose. There is no markdown rendering and no view/edit split,
+ * so there is no moment where the text on screen is not the text that would be
+ * sent — which is what makes "the agent gets exactly what you see" true rather
+ * than nearly true.
+ */
+export default function NoteEditor({
+ projectId,
+ title,
+ body,
+ onTitleChange,
+ onBodyChange,
+ onCommit,
+ onDelete,
+}: Props) {
+ return (
+
+
+ onTitleChange(e.target.value)}
+ onBlur={onCommit}
+ placeholder="Note title"
+ aria-label="Note title"
+ className="flex-1 min-w-0 px-2 h-8 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] text-[13px] text-[var(--text-primary)] focus:border-[var(--accent)] transition-colors"
+ />
+ {/* The live editor text, not `note.body` — what is on screen is what
+ gets sent. */}
+
+
+
+
+ );
+}
diff --git a/app/src/components/notes/NotesPanel.test.tsx b/app/src/components/notes/NotesPanel.test.tsx
new file mode 100644
index 0000000..7707f48
--- /dev/null
+++ b/app/src/components/notes/NotesPanel.test.tsx
@@ -0,0 +1,108 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import NotesPanel from "./NotesPanel";
+import type { Note } from "../../lib/types";
+
+const saveNote = vi.fn(async () => true);
+const deleteNote = vi.fn(async () => true);
+const createNote = vi.fn();
+let notes: Note[] = [];
+let loading = false;
+
+vi.mock("../../hooks/useNotes", () => ({
+ useNotes: () => ({
+ notes,
+ loading,
+ saveState: { status: "idle", error: null },
+ createNote,
+ saveNote,
+ deleteNote,
+ }),
+}));
+
+vi.mock("./SendToAgentButton", () => ({
+ default: ({ body }: { body: string }) => (
+
+ ),
+}));
+
+const note = (over: Partial = {}): Note => ({
+ id: "n1",
+ title: "Deploy steps",
+ body: "one\ntwo",
+ pinned: false,
+ created_at: "2026-09-01T00:00:00Z",
+ updated_at: "2026-09-01T00:00:00Z",
+ ...over,
+});
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ notes = [];
+ loading = false;
+});
+
+describe("NotesPanel", () => {
+ it("invites the user to start when there are no notes", () => {
+ render();
+ expect(screen.getByText(/no notes yet/i)).toBeInTheDocument();
+ });
+
+ it("lists notes by title and selects the first", () => {
+ notes = [note(), note({ id: "n2", title: "Gotchas" })];
+ render();
+ expect(screen.getByRole("button", { name: /deploy steps/i })).toBeInTheDocument();
+ expect(screen.getByLabelText("Note body")).toHaveValue("one\ntwo");
+ });
+
+ it("shows an untitled note under a placeholder rather than a blank row", () => {
+ notes = [note({ title: "" })];
+ render();
+ expect(screen.getByRole("button", { name: /untitled note/i })).toBeInTheDocument();
+ });
+
+ it("switches the editor when another note is selected", () => {
+ notes = [note(), note({ id: "n2", title: "Gotchas", body: "beware" })];
+ render();
+ fireEvent.click(screen.getByRole("button", { name: /gotchas/i }));
+ expect(screen.getByLabelText("Note body")).toHaveValue("beware");
+ });
+
+ it("saves on blur, not on every keystroke", async () => {
+ notes = [note()];
+ render();
+ const body = screen.getByLabelText("Note body");
+
+ fireEvent.change(body, { target: { value: "edited" } });
+ expect(saveNote).not.toHaveBeenCalled();
+
+ fireEvent.blur(body);
+ await waitFor(() => expect(saveNote).toHaveBeenCalledWith(
+ expect.objectContaining({ id: "n1", body: "edited" }),
+ ));
+ });
+
+ it("does not save on blur when nothing changed", async () => {
+ // Clicking through notes to read them must not write the file.
+ notes = [note()];
+ render();
+ fireEvent.blur(screen.getByLabelText("Note body"));
+ await waitFor(() => expect(saveNote).not.toHaveBeenCalled());
+ });
+
+ it("hands the live editor text to the send button, not the last saved copy", () => {
+ // Sending what is on screen is the whole contract: no transform on the way
+ // out except the newline substitution.
+ notes = [note()];
+ render();
+ fireEvent.change(screen.getByLabelText("Note body"), { target: { value: "fresh" } });
+ expect(screen.getByTestId("send")).toHaveTextContent("send:fresh");
+ });
+
+ it("deletes the selected note and falls back to another", async () => {
+ notes = [note(), note({ id: "n2", title: "Gotchas" })];
+ render();
+ fireEvent.click(screen.getByRole("button", { name: /delete note/i }));
+ await waitFor(() => expect(deleteNote).toHaveBeenCalledWith("n1"));
+ });
+});
diff --git a/app/src/components/notes/NotesPanel.tsx b/app/src/components/notes/NotesPanel.tsx
new file mode 100644
index 0000000..d75ab42
--- /dev/null
+++ b/app/src/components/notes/NotesPanel.tsx
@@ -0,0 +1,115 @@
+import { useEffect, useMemo, useState } from "react";
+import { useNotes } from "../../hooks/useNotes";
+import NoteEditor from "./NoteEditor";
+import Button from "../ui/Button";
+import SaveIndicator from "../ui/SaveIndicator";
+
+interface Props {
+ projectId: string;
+}
+
+const UNTITLED = "Untitled note";
+
+/**
+ * The notes surface itself, shared by the Project Home tab and the dock so the
+ * two cannot drift into different behaviour.
+ *
+ * Master/detail: titles on the left, one editor on the right. The editor holds
+ * draft text locally and commits on blur, which is how every other editable
+ * field in the app behaves (`ClaudeInstructionsEditor`, the Config tab).
+ */
+export default function NotesPanel({ projectId }: Props) {
+ const { notes, loading, saveState, createNote, saveNote, deleteNote } =
+ useNotes(projectId);
+ const [selectedId, setSelectedId] = useState(null);
+ const [title, setTitle] = useState("");
+ const [body, setBody] = useState("");
+
+ const selected = useMemo(
+ () => notes.find((n) => n.id === selectedId) ?? notes[0] ?? null,
+ [notes, selectedId],
+ );
+
+ // Load the selected note's stored text into the draft. Keyed on the id, not
+ // the note object, so a save round trip does not stomp what is being typed.
+ useEffect(() => {
+ if (!selected) {
+ setTitle("");
+ setBody("");
+ return;
+ }
+ setTitle(selected.title);
+ setBody(selected.body);
+ }, [selected?.id]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ const commit = () => {
+ if (!selected) return;
+ // Reading is not editing: clicking through notes must not rewrite the file.
+ if (title === selected.title && body === selected.body) return;
+ void saveNote({ ...selected, title, body });
+ };
+
+ const onCreate = async () => {
+ const note = await createNote();
+ if (note) setSelectedId(note.id);
+ };
+
+ if (loading) {
+ return (
+
Loading notes…
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ {notes.length === 0 ? (
+
+
+ No notes yet. Keep reminders here, and send any of them straight to a
+ running Claude session.
+
+ );
+}
diff --git a/app/src/components/projects/home/NotesTab.tsx b/app/src/components/projects/home/NotesTab.tsx
new file mode 100644
index 0000000..d10240f
--- /dev/null
+++ b/app/src/components/projects/home/NotesTab.tsx
@@ -0,0 +1,20 @@
+import type { Project } from "../../../lib/types";
+import NotesPanel from "../../notes/NotesPanel";
+
+interface Props {
+ project: Project;
+}
+
+/**
+ * Notes as a Project Home sub-tab.
+ *
+ * The same panel the dock shows. This is the roomy view for writing; the dock
+ * is the one that stays visible while the agent works.
+ */
+export default function NotesTab({ project }: Props) {
+ return (
+