From 3239057f8fede2e356a9d51199a6aca3112f4d19 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Wed, 2 Sep 2026 12:10:00 -0700 Subject: [PATCH] Give the dock its own compact notes layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dock was showing `NotesPanel`, which is a master/detail layout: a column of titles beside an editor. The previous commit made that survive dock width; it did not make it right. At 352px the layout still spends roughly 356px of height on chrome — dock header, panel header, title strip, a button row that wraps, and a paragraph of help — before the body gets a pixel. So the dock now shows one note. The title field names what is open and the chevron beside it switches; New and Delete move into the overflow menu; the help text goes. Chrome drops to about 112px and the body takes the rest. The two surfaces are now different components, which contradicts a docstring I wrote — "shared so the two cannot drift into different behaviour". That claim was about behaviour, and behaviour was never in the layout: it is in `useNotes` for the cache and its write ordering, and now in `useNoteDraft`, extracted here so when a keystroke becomes a save is defined in exactly one place. Only the layout diverges. `NotesPanel.shared.test.tsx` gets stronger for it — it now mounts the dock panel and the tab panel together, which is what the app actually does, instead of the same component twice. `NoteSwitcher` is not `OverflowMenu` despite the shape being close: that keys items by label, and notes are addressed by id, so two untitled notes — the ordinary case — would collapse into one row. It is also not a `combobox`; an input plus a listbox button is two honest controls, where the role would owe active-descendant tracking and filtering that nothing here needs. `SendToAgentButton` picks up `useUnavailable` from #49, which is what its `disabled` plus explanatory `title` was already asking for. Four tests moved from `toBeDisabled()` to the new contract, and one of them — "does nothing for an empty note" — turned out never to have asserted that it does nothing. It does now, for click and for Enter, which is the guard the swap needs. It also gains `dropUp`, and that is load-bearing rather than cosmetic: the dock clips its own overflow, so a session menu opening downward from a button on the bottom edge is drawn outside the panel and never seen. 744 tests pass, 62 files. As before, jsdom has no layout engine: that the dock now reads as compact is not something the suite can tell you. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm --- app/src/components/layout/NotesDock.test.tsx | 2 +- app/src/components/layout/NotesDock.tsx | 4 +- .../components/notes/NoteSwitcher.test.tsx | 115 ++++++++++++++ app/src/components/notes/NoteSwitcher.tsx | 112 ++++++++++++++ .../components/notes/NotesDockPanel.test.tsx | 143 ++++++++++++++++++ app/src/components/notes/NotesDockPanel.tsx | 119 +++++++++++++++ .../notes/NotesPanel.shared.test.tsx | 28 ++-- app/src/components/notes/NotesPanel.tsx | 49 +----- .../notes/SendToAgentButton.test.tsx | 63 +++++++- .../components/notes/SendToAgentButton.tsx | 34 ++++- app/src/components/notes/useNoteDraft.ts | 61 ++++++++ 11 files changed, 661 insertions(+), 69 deletions(-) create mode 100644 app/src/components/notes/NoteSwitcher.test.tsx create mode 100644 app/src/components/notes/NoteSwitcher.tsx create mode 100644 app/src/components/notes/NotesDockPanel.test.tsx create mode 100644 app/src/components/notes/NotesDockPanel.tsx create mode 100644 app/src/components/notes/useNoteDraft.ts diff --git a/app/src/components/layout/NotesDock.test.tsx b/app/src/components/layout/NotesDock.test.tsx index ceaee6d..f941284 100644 --- a/app/src/components/layout/NotesDock.test.tsx +++ b/app/src/components/layout/NotesDock.test.tsx @@ -3,7 +3,7 @@ import { render, screen, fireEvent } from "@testing-library/react"; import NotesDock from "./NotesDock"; import type { Project, TerminalSession } from "../../lib/types"; -vi.mock("../notes/NotesPanel", () => ({ +vi.mock("../notes/NotesDockPanel", () => ({ default: ({ projectId }: { projectId: string }) => (
{`panel:${projectId}`}
), diff --git a/app/src/components/layout/NotesDock.tsx b/app/src/components/layout/NotesDock.tsx index 3ce4916..4619bb1 100644 --- a/app/src/components/layout/NotesDock.tsx +++ b/app/src/components/layout/NotesDock.tsx @@ -7,7 +7,7 @@ import { NOTES_DOCK_MIN_WIDTH, NOTES_DOCK_MAX_WIDTH, } from "../../store/appState"; -import NotesPanel from "../notes/NotesPanel"; +import NotesDockPanel from "../notes/NotesDockPanel"; import Button from "../ui/Button"; /** @@ -116,7 +116,7 @@ export default function NotesDock() {
{projectId ? ( - + ) : (

Open a project or a terminal to see its notes. diff --git a/app/src/components/notes/NoteSwitcher.test.tsx b/app/src/components/notes/NoteSwitcher.test.tsx new file mode 100644 index 0000000..144112d --- /dev/null +++ b/app/src/components/notes/NoteSwitcher.test.tsx @@ -0,0 +1,115 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import NoteSwitcher from "./NoteSwitcher"; +import type { Note } from "../../lib/types"; + +const onTitleChange = vi.fn(); +const onCommit = vi.fn(); +const onSelect = vi.fn(); + +const note = (over: Partial = {}): Note => ({ + id: "n1", + title: "Deploy steps", + body: "", + pinned: false, + created_at: "2026-09-01T00:00:00Z", + updated_at: "2026-09-01T00:00:00Z", + ...over, +}); + +const setup = (notes: Note[], selectedId = notes[0]?.id ?? "", title = notes[0]?.title ?? "") => + render( + , + ); + +beforeEach(() => vi.clearAllMocks()); + +describe("NoteSwitcher", () => { + it("edits the title in place, committing on blur", () => { + setup([note()]); + const field = screen.getByLabelText("Note title"); + expect(field).toHaveValue("Deploy steps"); + + fireEvent.change(field, { target: { value: "Deploy steps v2" } }); + expect(onTitleChange).toHaveBeenCalledWith("Deploy steps v2"); + expect(onCommit).not.toHaveBeenCalled(); + + fireEvent.blur(field); + expect(onCommit).toHaveBeenCalledTimes(1); + }); + + it("keeps the other notes out of the way until asked for", () => { + setup([note(), note({ id: "n2", title: "Gotchas" })]); + expect(screen.queryByText("Gotchas")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /switch note/i })); + expect(screen.getByRole("option", { name: "Gotchas" })).toBeInTheDocument(); + }); + + it("reports whether the list is open", () => { + setup([note()]); + const trigger = screen.getByRole("button", { name: /switch note/i }); + expect(trigger).toHaveAttribute("aria-expanded", "false"); + + fireEvent.click(trigger); + expect(trigger).toHaveAttribute("aria-expanded", "true"); + }); + + it("marks the current note as the selected option", () => { + setup([note(), note({ id: "n2", title: "Gotchas" })], "n2", "Gotchas"); + fireEvent.click(screen.getByRole("button", { name: /switch note/i })); + + expect(screen.getByRole("option", { name: "Gotchas" })).toHaveAttribute( + "aria-selected", + "true", + ); + expect(screen.getByRole("option", { name: "Deploy steps" })).toHaveAttribute( + "aria-selected", + "false", + ); + }); + + it("selects a note and closes", () => { + setup([note(), note({ id: "n2", title: "Gotchas" })]); + fireEvent.click(screen.getByRole("button", { name: /switch note/i })); + fireEvent.click(screen.getByRole("option", { name: "Gotchas" })); + + expect(onSelect).toHaveBeenCalledWith("n2"); + expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); + }); + + it("names an untitled note rather than showing an empty row", () => { + setup([note({ title: " " })]); + fireEvent.click(screen.getByRole("button", { name: /switch note/i })); + expect(screen.getByRole("option", { name: "Untitled note" })).toBeInTheDocument(); + }); + + // Notes are addressed by id, never by title. Two untitled notes are the + // ordinary case, and a title-keyed list would collapse them into one row. + it("lists two notes that share a title as two options", () => { + setup([note({ id: "n1", title: "" }), note({ id: "n2", title: "" })]); + fireEvent.click(screen.getByRole("button", { name: /switch note/i })); + + const options = screen.getAllByRole("option", { name: "Untitled note" }); + expect(options).toHaveLength(2); + + fireEvent.click(options[1]); + expect(onSelect).toHaveBeenCalledWith("n2"); + }); + + it("closes on Escape without selecting anything", () => { + setup([note(), note({ id: "n2", title: "Gotchas" })]); + fireEvent.click(screen.getByRole("button", { name: /switch note/i })); + fireEvent.keyDown(document, { key: "Escape" }); + + expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); + expect(onSelect).not.toHaveBeenCalled(); + }); +}); diff --git a/app/src/components/notes/NoteSwitcher.tsx b/app/src/components/notes/NoteSwitcher.tsx new file mode 100644 index 0000000..b31054a --- /dev/null +++ b/app/src/components/notes/NoteSwitcher.tsx @@ -0,0 +1,112 @@ +import { useEffect, useRef, useState } from "react"; +import type { Note } from "../../lib/types"; + +export const UNTITLED = "Untitled note"; + +interface Props { + notes: Note[]; + selectedId: string; + title: string; + onTitleChange: (value: string) => void; + onCommit: () => void; + onSelect: (id: string) => void; +} + +/** + * One row that both names the current note and switches to another. + * + * The dock has no room for a permanent list of titles, so the title field + * doubles as the label of what is open and the chevron beside it holds the + * rest. Renaming therefore needs no separate affordance. + * + * Two honest controls rather than one `role="combobox"`: a text field and a + * button that opens a listbox. A real combobox owes its listbox keyboard + * navigation, active-descendant tracking and an input that filters — none of + * which this needs, and half of which is worse than not claiming the role. + * + * `OverflowMenu` is deliberately not reused here despite the shape being + * close. It keys its items by label, and notes are addressed by id: two + * untitled notes are the ordinary case and would collapse into one row. + */ +export default function NoteSwitcher({ + notes, + selectedId, + title, + onTitleChange, + onCommit, + onSelect, +}: Props) { + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + + // Same dismissal contract as `OverflowMenu`, so the two feel identical. + useEffect(() => { + if (!open) return; + const onDocClick = (e: MouseEvent) => { + if (!rootRef.current?.contains(e.target as Node)) setOpen(false); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setOpen(false); + }; + document.addEventListener("mousedown", onDocClick); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onDocClick); + document.removeEventListener("keydown", onKey); + }; + }, [open]); + + return ( +

+ onTitleChange(e.target.value)} + onBlur={onCommit} + placeholder="Note title" + aria-label="Note title" + className="flex-1 min-w-0 px-2 h-7 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" + /> + + {open && ( +
+ {/* 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), + }, + ]} + /> +
    + +