+ {/* Buttons directly inside the listbox: wrapping each in an `
`
+ would put an implicit `listitem` between the listbox and its
+ options, which is not a child role a listbox owns. */}
+ {notes.map((n) => (
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/app/src/components/notes/NotesDockPanel.test.tsx b/app/src/components/notes/NotesDockPanel.test.tsx
new file mode 100644
index 0000000..1f4fc01
--- /dev/null
+++ b/app/src/components/notes/NotesDockPanel.test.tsx
@@ -0,0 +1,143 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import NotesDockPanel from "./NotesDockPanel";
+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,
+ }),
+}));
+
+const sendProps: Record[] = [];
+vi.mock("./SendToAgentButton", () => ({
+ default: (props: Record) => {
+ sendProps.push(props);
+ return ;
+ },
+}));
+
+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();
+ sendProps.length = 0;
+ notes = [];
+ loading = false;
+});
+
+describe("NotesDockPanel", () => {
+ it("says it is loading rather than flashing an empty state", () => {
+ loading = true;
+ render();
+ expect(screen.getByText(/loading notes/i)).toBeInTheDocument();
+ });
+
+ it("offers a first note when the project has none", async () => {
+ render();
+ fireEvent.click(screen.getByRole("button", { name: /new note/i }));
+ await waitFor(() => expect(createNote).toHaveBeenCalled());
+ });
+
+ // The point of the redesign: the dock spends its height on the note being
+ // written, not on a permanent list of the ones that are not.
+ it("shows one note at a time, the rest behind the switcher", () => {
+ notes = [note(), note({ id: "n2", title: "Gotchas" })];
+ render();
+
+ expect(screen.getByLabelText("Note title")).toHaveValue("Deploy steps");
+ expect(screen.queryByText("Gotchas")).not.toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole("button", { name: /switch note/i }));
+ expect(screen.getByRole("option", { name: "Gotchas" })).toBeInTheDocument();
+ });
+
+ it("switches to the note picked from the list", () => {
+ notes = [note(), note({ id: "n2", title: "Gotchas", body: "careful" })];
+ render();
+
+ fireEvent.click(screen.getByRole("button", { name: /switch note/i }));
+ fireEvent.click(screen.getByRole("option", { name: "Gotchas" }));
+
+ expect(screen.getByLabelText("Note title")).toHaveValue("Gotchas");
+ expect(screen.getByLabelText("Note body")).toHaveValue("careful");
+ });
+
+ it("saves the body when it loses focus, and not before", () => {
+ notes = [note()];
+ render();
+ const body = screen.getByLabelText("Note body");
+
+ fireEvent.change(body, { target: { value: "one\ntwo\nthree" } });
+ expect(saveNote).not.toHaveBeenCalled();
+
+ fireEvent.blur(body);
+ expect(saveNote).toHaveBeenCalledWith(
+ expect.objectContaining({ id: "n1", body: "one\ntwo\nthree" }),
+ );
+ });
+
+ it("keeps New and Delete in the overflow menu, out of the writing area", async () => {
+ notes = [note()];
+ render();
+ fireEvent.click(screen.getByRole("button", { name: /note actions/i }));
+
+ fireEvent.click(screen.getByRole("menuitem", { name: /delete note/i }));
+ await waitFor(() => expect(deleteNote).toHaveBeenCalledWith("n1"));
+ });
+
+ it("opens the note it just created", async () => {
+ notes = [note()];
+ createNote.mockResolvedValueOnce(note({ id: "n9", title: "" }));
+ const view = render();
+
+ fireEvent.click(screen.getByRole("button", { name: /note actions/i }));
+ fireEvent.click(screen.getByRole("menuitem", { name: /new note/i }));
+ await waitFor(() => expect(createNote).toHaveBeenCalled());
+
+ notes = [note(), note({ id: "n9", title: "" })];
+ view.rerender();
+ await waitFor(() =>
+ expect(screen.getByLabelText("Note title")).toHaveValue(""),
+ );
+ });
+
+ // The send bar sits on the dock's bottom edge, inside an `overflow-hidden`
+ // panel, so both of these are load-bearing rather than cosmetic.
+ it("sends from a full-width bar whose menu opens upward", () => {
+ notes = [note()];
+ render();
+
+ expect(screen.getByRole("button", { name: /send to agent/i })).toBeInTheDocument();
+ expect(sendProps.at(-1)).toMatchObject({ fullWidth: true, dropUp: true });
+ });
+
+ it("sends what is on screen, not what was last saved", () => {
+ notes = [note()];
+ render();
+ fireEvent.change(screen.getByLabelText("Note body"), {
+ target: { value: "edited but not blurred" },
+ });
+
+ expect(sendProps.at(-1)).toMatchObject({ body: "edited but not blurred" });
+ });
+});
diff --git a/app/src/components/notes/NotesDockPanel.tsx b/app/src/components/notes/NotesDockPanel.tsx
new file mode 100644
index 0000000..dcd5913
--- /dev/null
+++ b/app/src/components/notes/NotesDockPanel.tsx
@@ -0,0 +1,119 @@
+import { useMemo, useState } from "react";
+import { useNotes } from "../../hooks/useNotes";
+import { useNoteDraft } from "./useNoteDraft";
+import NoteSwitcher from "./NoteSwitcher";
+import SendToAgentButton from "./SendToAgentButton";
+import Button from "../ui/Button";
+import OverflowMenu from "../ui/OverflowMenu";
+import SaveIndicator from "../ui/SaveIndicator";
+
+interface Props {
+ projectId: string;
+}
+
+/**
+ * Notes at dock width.
+ *
+ * Deliberately not `NotesPanel` in a narrower box. The tab can afford a column
+ * of titles beside the editor; the dock cannot, and shrinking that layout
+ * spends its height on chrome — a title strip, a wrapped button row and a
+ * paragraph of help — for a body that ends up a few words wide.
+ *
+ * So the dock shows exactly one note. The title row names it and switches to
+ * another, the actions that are not writing live in the overflow menu, and
+ * everything left over is the body. Roughly 240px of height comes back.
+ *
+ * What the two surfaces share is the part that must not drift: `useNotes` for
+ * the cache and its write ordering, and `useNoteDraft` for when a keystroke
+ * becomes a save. Only the layout is different.
+ */
+export default function NotesDockPanel({ projectId }: Props) {
+ const { notes, loading, saveState, createNote, saveNote, deleteNote } =
+ useNotes(projectId);
+ const [selectedId, setSelectedId] = useState(null);
+
+ const selected = useMemo(
+ () => notes.find((n) => n.id === selectedId) ?? notes[0] ?? null,
+ [notes, selectedId],
+ );
+
+ const { title, body, setTitle, setBody, commit } = useNoteDraft(
+ selected,
+ saveNote,
+ );
+
+ const onCreate = async () => {
+ const note = await createNote();
+ if (note) setSelectedId(note.id);
+ };
+
+ if (loading) {
+ return (
+
Loading notes…
+ );
+ }
+
+ if (!selected) {
+ return (
+
+
+ Keep reminders here, and send any of them straight to a running Claude
+ session.
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {/* Renders nothing while idle, so it costs no width until it matters. */}
+
+ void onCreate() },
+ {
+ label: "Delete note",
+ danger: true,
+ onSelect: () => void deleteNote(selected.id),
+ },
+ ]}
+ />
+
+
+
+ );
+}
diff --git a/app/src/components/notes/NotesPanel.shared.test.tsx b/app/src/components/notes/NotesPanel.shared.test.tsx
index fc459ce..415ba3d 100644
--- a/app/src/components/notes/NotesPanel.shared.test.tsx
+++ b/app/src/components/notes/NotesPanel.shared.test.tsx
@@ -1,19 +1,24 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, within, fireEvent, waitFor } from "@testing-library/react";
import NotesPanel from "./NotesPanel";
+import NotesDockPanel from "./NotesDockPanel";
import { useAppState } from "../../store/appState";
import type { Note } from "../../lib/types";
/**
* Two panels, one project — the configuration the app actually runs in.
*
- * `NotesTab` and `NotesDock` both mount a `NotesPanel`, and the dock follows
- * the active tab's project, so opening the dock over a Project Home tab mounts
- * two panels for the *same* project. Every other notes test mounts exactly
- * one, which is precisely the configuration in which a per-panel cache looks
- * correct: it is only with two that an edit made in one is seen — or lost — by
- * the other. `useNotes` is deliberately **not** mocked here; the cache is what
- * is under test.
+ * `NotesTab` mounts a `NotesPanel` and `NotesDock` mounts a `NotesDockPanel`,
+ * and the dock follows the active tab's project, so opening the dock over a
+ * Project Home tab mounts both for the *same* project. Every other notes test
+ * mounts exactly one, which is precisely the configuration in which a
+ * per-panel cache looks correct: it is only with two that an edit made in one
+ * is seen — or lost — by the other. `useNotes` is deliberately **not** mocked
+ * here; the cache is what is under test.
+ *
+ * The two are different components on purpose, which is exactly why this test
+ * pairs them rather than mounting the same one twice: the layouts diverged,
+ * and the cache and draft rules they share are what must not.
*/
const files: Record = {};
@@ -54,7 +59,7 @@ function renderBothSurfaces() {
-
+
>,
);
@@ -70,7 +75,7 @@ beforeEach(() => {
useAppState.setState({ notesByProject: {}, notesLoading: {}, toasts: [] });
});
-describe("NotesPanel with the tab and the dock both open", () => {
+describe("the tab and the dock both open on one project", () => {
it("shows an edit made in one surface in the other", async () => {
const { tab, dock } = renderBothSurfaces();
await waitFor(() => expect(tab().getByLabelText("Note body")).toHaveValue("one"));
@@ -157,7 +162,10 @@ describe("NotesPanel with the tab and the dock both open", () => {
const { tab, dock } = renderBothSurfaces();
await waitFor(() => expect(tab().getByLabelText("Note body")).toHaveValue("one"));
- fireEvent.click(dock().getByRole("button", { name: /new note/i }));
+ // The dock keeps New behind its overflow menu — its height belongs to the
+ // note being written, not to a button row.
+ fireEvent.click(dock().getByRole("button", { name: /note actions/i }));
+ fireEvent.click(dock().getByRole("menuitem", { name: /new note/i }));
await waitFor(() =>
expect(tab().getAllByRole("button", { name: /untitled note/i })).toHaveLength(1),
diff --git a/app/src/components/notes/NotesPanel.tsx b/app/src/components/notes/NotesPanel.tsx
index ba136d4..6737ff6 100644
--- a/app/src/components/notes/NotesPanel.tsx
+++ b/app/src/components/notes/NotesPanel.tsx
@@ -1,5 +1,6 @@
-import { useEffect, useMemo, useRef, useState } from "react";
+import { useMemo, useState } from "react";
import { useNotes } from "../../hooks/useNotes";
+import { useNoteDraft } from "./useNoteDraft";
import NoteEditor from "./NoteEditor";
import Button from "../ui/Button";
import SaveIndicator from "../ui/SaveIndicator";
@@ -34,54 +35,16 @@ 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],
);
- // What was last copied out of the store into the draft fields. The draft is
- // "untouched" exactly while it still matches this, which is how an edit made
- // somewhere else can be shown without ever discarding something half-typed.
- const seeded = useRef<{ id: string | null; title: string; body: string }>({
- id: null,
- title: "",
- body: "",
- });
-
- // Load the selected note's stored text into the draft — on a change of note,
- // and on a change to the *stored* text of the note already selected. The
- // second case is the dock and the tab showing one project at once: an edit
- // committed in one surface has to reach the other's editor, not just its
- // list. It never overwrites text the user is part-way through typing; that
- // blurs into a last-writer-wins save, as any blur-commit editor does.
- useEffect(() => {
- if (!selected) {
- seeded.current = { id: null, title: "", body: "" };
- setTitle("");
- setBody("");
- return;
- }
- const untouched =
- title === seeded.current.title && body === seeded.current.body;
- if (seeded.current.id !== selected.id || untouched) {
- seeded.current = { id: selected.id, title: selected.title, body: selected.body };
- setTitle(selected.title);
- setBody(selected.body);
- }
- }, [selected?.id, selected?.title, selected?.body]); // 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;
- // Mark the draft as matching what was just committed, so the store update
- // this save produces reads as "no change" rather than as a stale re-seed.
- seeded.current = { id: selected.id, title, body };
- void saveNote({ ...selected, title, body });
- };
+ const { title, body, setTitle, setBody, commit } = useNoteDraft(
+ selected,
+ saveNote,
+ );
const onCreate = async () => {
const note = await createNote();
diff --git a/app/src/components/notes/SendToAgentButton.test.tsx b/app/src/components/notes/SendToAgentButton.test.tsx
index 884f915..7197aab 100644
--- a/app/src/components/notes/SendToAgentButton.test.tsx
+++ b/app/src/components/notes/SendToAgentButton.test.tsx
@@ -39,23 +39,55 @@ beforeEach(() => {
});
describe("SendToAgentButton", () => {
- it("is disabled when the project has no running session", () => {
+ // Unavailable, not `disabled`: the reason a note cannot be sent is the whole
+ // content of these states, and native `disabled` announces it to nobody.
+ it("says why it cannot send when the project has no running session", () => {
render();
- expect(screen.getByRole("button", { name: /send to agent/i })).toBeDisabled();
+ const button = screen.getByRole("button", { name: /send to agent/i });
+ expect(button).toHaveAttribute("aria-disabled", "true");
+ expect(button).toHaveAccessibleDescription(
+ "No running Claude session for this project",
+ );
});
- it("is disabled when the only session belongs to another project", () => {
+ it("says why it cannot send an empty note", () => {
+ sessions = [session()];
+ render();
+ expect(
+ screen.getByRole("button", { name: /send to agent/i }),
+ ).toHaveAccessibleDescription("Nothing to send — this note is empty");
+ });
+
+ it("is unavailable when the only session belongs to another project", () => {
sessions = [session({ projectId: "other" })];
render();
- expect(screen.getByRole("button", { name: /send to agent/i })).toBeDisabled();
+ expect(
+ screen.getByRole("button", { name: /send to agent/i }),
+ ).toHaveAttribute("aria-disabled", "true");
});
- it("is disabled when the only session is a bash tab", () => {
+ it("is unavailable when the only session is a bash tab", () => {
// `bash -l`'s readline has no binding for ESC+CR and just bells, so a
// shell is never a target.
sessions = [session({ sessionType: "bash" })];
render();
- expect(screen.getByRole("button", { name: /send to agent/i })).toBeDisabled();
+ expect(
+ screen.getByRole("button", { name: /send to agent/i }),
+ ).toHaveAttribute("aria-disabled", "true");
+ });
+
+ // `aria-disabled` is advisory — it blocks nothing on its own. Without the
+ // guard this swap would turn a greyed-out button into a live one.
+ it("sends nothing when activated while unavailable", () => {
+ render();
+ const button = screen.getByRole("button", { name: /send to agent/i });
+
+ fireEvent.click(button);
+ fireEvent.keyDown(button, { key: "Enter" });
+ fireEvent.keyDown(button, { key: " " });
+
+ expect(sendInput).not.toHaveBeenCalled();
+ expect(screen.queryByRole("menu")).not.toBeInTheDocument();
});
it("sends straight to the one session, with newlines converted and no terminator", async () => {
@@ -100,6 +132,21 @@ describe("SendToAgentButton", () => {
it("does nothing for an empty note", () => {
sessions = [session()];
render();
- expect(screen.getByRole("button", { name: /send to agent/i })).toBeDisabled();
+ const button = screen.getByRole("button", { name: /send to agent/i });
+ expect(button).toHaveAttribute("aria-disabled", "true");
+
+ fireEvent.click(button);
+ fireEvent.keyDown(button, { key: "Enter" });
+ expect(sendInput).not.toHaveBeenCalled();
});
-});
+
+ it("opens the session menu upward when it sits at the foot of the dock", async () => {
+ sessions = [session({ id: "s1" }), session({ id: "s2" })];
+ render();
+ fireEvent.click(screen.getByRole("button", { name: /send to agent/i }));
+
+ // Anchored to the button's top edge, not below it: the dock clips its own
+ // overflow, so a downward menu at the bottom edge is invisible.
+ await waitFor(() => expect(screen.getByRole("menu")).toHaveClass("bottom-full"));
+ });
+});
\ No newline at end of file
diff --git a/app/src/components/notes/SendToAgentButton.tsx b/app/src/components/notes/SendToAgentButton.tsx
index 9868485..38e808e 100644
--- a/app/src/components/notes/SendToAgentButton.tsx
+++ b/app/src/components/notes/SendToAgentButton.tsx
@@ -9,6 +9,14 @@ import Button from "../ui/Button";
interface Props {
projectId: string;
body: string;
+ /**
+ * Open the session menu above the button instead of below. The dock puts
+ * this at its foot, and the dock clips its own overflow, so a downward menu
+ * there is drawn outside the panel and never seen.
+ */
+ dropUp?: boolean;
+ /** Fill the row. The dock's send bar is the width of the dock. */
+ fullWidth?: boolean;
}
/**
@@ -21,7 +29,12 @@ interface Props {
* Only `claude` sessions are offered. A bash tab would receive ESC+CR as an
* unbound readline key and answer with a bell (see `lib/claudeInput.ts`).
*/
-export default function SendToAgentButton({ projectId, body }: Props) {
+export default function SendToAgentButton({
+ projectId,
+ body,
+ dropUp = false,
+ fullWidth = false,
+}: Props) {
const { sessions, sendInput } = useTerminal();
const { projects, setActiveTabKey, pushToast } = useAppState(
useShallow((s) => ({
@@ -43,7 +56,7 @@ export default function SendToAgentButton({ projectId, body }: Props) {
const project = projects.find((p) => p.id === projectId);
const hasBody = body.trim().length > 0;
- const disabled = targets.length === 0 || !hasBody;
+ const unavailable = targets.length === 0 || !hasBody;
// Same dismissal contract as `ui/OverflowMenu` and the tab context menu.
useEffect(() => {
@@ -102,10 +115,19 @@ export default function SendToAgentButton({ projectId, body }: Props) {
: "Put this note into the agent's prompt (you press Enter)";
return (
-