Give the dock its own compact notes layout
Secret Scan / scan (push) Successful in 5s
Build App (Preview) / compute-version (pull_request) Successful in 3s
Secret Scan / scan (pull_request) Successful in 3s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m44s
Build App (Preview) / build-windows (pull_request) Successful in 4m55s
Build App (Preview) / build-linux (pull_request) Successful in 5m29s
Build App (Preview) / prune-previews (pull_request) Successful in 1s

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm
This commit is contained in:
2026-09-02 12:10:00 -07:00
co-authored by Claude Opus 5
parent 23364f412e
commit 3239057f8f
11 changed files with 661 additions and 69 deletions
@@ -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(<SendToAgentButton projectId="p1" body="hello" />);
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(<SendToAgentButton projectId="p1" body=" " />);
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(<SendToAgentButton projectId="p1" body="hello" />);
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(<SendToAgentButton projectId="p1" body="hello" />);
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(<SendToAgentButton projectId="p1" body="hello" />);
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(<SendToAgentButton projectId="p1" body=" " />);
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(<SendToAgentButton projectId="p1" body="hello" dropUp />);
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"));
});
});