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
+1 -1
View File
@@ -3,7 +3,7 @@ import { render, screen, fireEvent } from "@testing-library/react";
import NotesDock from "./NotesDock"; import NotesDock from "./NotesDock";
import type { Project, TerminalSession } from "../../lib/types"; import type { Project, TerminalSession } from "../../lib/types";
vi.mock("../notes/NotesPanel", () => ({ vi.mock("../notes/NotesDockPanel", () => ({
default: ({ projectId }: { projectId: string }) => ( default: ({ projectId }: { projectId: string }) => (
<div data-testid="panel">{`panel:${projectId}`}</div> <div data-testid="panel">{`panel:${projectId}`}</div>
), ),
+2 -2
View File
@@ -7,7 +7,7 @@ import {
NOTES_DOCK_MIN_WIDTH, NOTES_DOCK_MIN_WIDTH,
NOTES_DOCK_MAX_WIDTH, NOTES_DOCK_MAX_WIDTH,
} from "../../store/appState"; } from "../../store/appState";
import NotesPanel from "../notes/NotesPanel"; import NotesDockPanel from "../notes/NotesDockPanel";
import Button from "../ui/Button"; import Button from "../ui/Button";
/** /**
@@ -116,7 +116,7 @@ export default function NotesDock() {
</div> </div>
<div className="flex-1 min-h-0"> <div className="flex-1 min-h-0">
{projectId ? ( {projectId ? (
<NotesPanel projectId={projectId} /> <NotesDockPanel projectId={projectId} />
) : ( ) : (
<p className="p-4 text-[13px] text-[var(--text-secondary)]"> <p className="p-4 text-[13px] text-[var(--text-secondary)]">
Open a project or a terminal to see its notes. Open a project or a terminal to see its notes.
@@ -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> = {}): 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(
<NoteSwitcher
notes={notes}
selectedId={selectedId}
title={title}
onTitleChange={onTitleChange}
onCommit={onCommit}
onSelect={onSelect}
/>,
);
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();
});
});
+112
View File
@@ -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<HTMLDivElement>(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 (
<div ref={rootRef} className="relative flex items-center gap-1 min-w-0">
<input
value={title}
onChange={(e) => 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"
/>
<button
type="button"
aria-label="Switch note"
aria-haspopup="listbox"
aria-expanded={open}
onClick={() => setOpen((o) => !o)}
className="inline-flex items-center justify-center h-7 w-6 flex-shrink-0 rounded-[var(--radius-control)] border border-[var(--border-color)] bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--border-color)] transition-colors"
>
<span aria-hidden="true" className="leading-none text-[10px]"></span>
</button>
{open && (
<div
role="listbox"
aria-label="Notes"
className="absolute right-0 top-full mt-1 z-40 w-full max-h-64 overflow-y-auto py-1 bg-[var(--bg-overlay)] border border-[var(--border-color)] rounded-[var(--radius-panel)]"
style={{ boxShadow: "var(--shadow-overlay)" }}
>
{/* Buttons directly inside the listbox: wrapping each in an `<li>`
would put an implicit `listitem` between the listbox and its
options, which is not a child role a listbox owns. */}
{notes.map((n) => (
<button
key={n.id}
type="button"
role="option"
aria-selected={n.id === selectedId}
onClick={() => {
onSelect(n.id);
setOpen(false);
}}
className={`block w-full text-left px-3 py-1.5 text-xs truncate transition-colors hover:bg-[var(--bg-tertiary)] ${
n.id === selectedId
? "text-[var(--text-primary)] bg-[var(--bg-tertiary)]"
: "text-[var(--text-secondary)]"
}`}
>
{n.title.trim() || UNTITLED}
</button>
))}
</div>
)}
</div>
);
}
@@ -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<string, unknown>[] = [];
vi.mock("./SendToAgentButton", () => ({
default: (props: Record<string, unknown>) => {
sendProps.push(props);
return <button type="button">Send to agent</button>;
},
}));
const note = (over: Partial<Note> = {}): 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(<NotesDockPanel projectId="p1" />);
expect(screen.getByText(/loading notes/i)).toBeInTheDocument();
});
it("offers a first note when the project has none", async () => {
render(<NotesDockPanel projectId="p1" />);
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(<NotesDockPanel projectId="p1" />);
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(<NotesDockPanel projectId="p1" />);
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(<NotesDockPanel projectId="p1" />);
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(<NotesDockPanel projectId="p1" />);
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(<NotesDockPanel projectId="p1" />);
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(<NotesDockPanel projectId="p1" />);
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(<NotesDockPanel projectId="p1" />);
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(<NotesDockPanel projectId="p1" />);
fireEvent.change(screen.getByLabelText("Note body"), {
target: { value: "edited but not blurred" },
});
expect(sendProps.at(-1)).toMatchObject({ body: "edited but not blurred" });
});
});
+119
View File
@@ -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<string | null>(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 (
<p className="p-4 text-xs text-[var(--text-secondary)]">Loading notes</p>
);
}
if (!selected) {
return (
<div className="flex-1 flex flex-col items-center justify-center gap-3 p-4">
<p className="text-[13px] text-[var(--text-secondary)] text-center">
Keep reminders here, and send any of them straight to a running Claude
session.
</p>
<Button variant="primary" onClick={onCreate}>
New note
</Button>
</div>
);
}
return (
<div className="flex flex-col h-full min-h-0">
<div className="flex items-center gap-1 px-2 py-1.5 flex-shrink-0 border-b border-[var(--border-color)]">
<div className="flex-1 min-w-0">
<NoteSwitcher
notes={notes}
selectedId={selected.id}
title={title}
onTitleChange={setTitle}
onCommit={commit}
onSelect={setSelectedId}
/>
</div>
{/* Renders nothing while idle, so it costs no width until it matters. */}
<SaveIndicator state={saveState} />
<OverflowMenu
label="Note actions"
items={[
{ label: "New note", onSelect: () => void onCreate() },
{
label: "Delete note",
danger: true,
onSelect: () => void deleteNote(selected.id),
},
]}
/>
</div>
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
onBlur={commit}
placeholder="Reminders, gotchas, a prompt worth keeping…"
aria-label="Note body"
className="flex-1 min-h-0 w-full px-3 py-2 bg-transparent text-[13px] text-[var(--text-primary)] resize-none font-mono"
/>
<div className="px-2 py-2 flex-shrink-0 border-t border-[var(--border-color)]">
{/* The live draft, not `selected.body` — what is on screen is what gets
sent. `dropUp` because the dock clips its own overflow. */}
<SendToAgentButton
projectId={projectId}
body={body}
fullWidth
dropUp
/>
</div>
</div>
);
}
@@ -1,19 +1,24 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, within, fireEvent, waitFor } from "@testing-library/react"; import { render, screen, within, fireEvent, waitFor } from "@testing-library/react";
import NotesPanel from "./NotesPanel"; import NotesPanel from "./NotesPanel";
import NotesDockPanel from "./NotesDockPanel";
import { useAppState } from "../../store/appState"; import { useAppState } from "../../store/appState";
import type { Note } from "../../lib/types"; import type { Note } from "../../lib/types";
/** /**
* Two panels, one project — the configuration the app actually runs in. * Two panels, one project — the configuration the app actually runs in.
* *
* `NotesTab` and `NotesDock` both mount a `NotesPanel`, and the dock follows * `NotesTab` mounts a `NotesPanel` and `NotesDock` mounts a `NotesDockPanel`,
* the active tab's project, so opening the dock over a Project Home tab mounts * and the dock follows the active tab's project, so opening the dock over a
* two panels for the *same* project. Every other notes test mounts exactly * Project Home tab mounts both for the *same* project. Every other notes test
* one, which is precisely the configuration in which a per-panel cache looks * mounts exactly one, which is precisely the configuration in which a
* correct: it is only with two that an edit made in one is seen — or lost — by * per-panel cache looks correct: it is only with two that an edit made in one
* the other. `useNotes` is deliberately **not** mocked here; the cache is what * is seen — or lost — by the other. `useNotes` is deliberately **not** mocked
* is under test. * 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<string, Note[]> = {}; const files: Record<string, Note[]> = {};
@@ -54,7 +59,7 @@ function renderBothSurfaces() {
<NotesPanel projectId="p1" /> <NotesPanel projectId="p1" />
</div> </div>
<div data-testid="dock"> <div data-testid="dock">
<NotesPanel projectId="p1" /> <NotesDockPanel projectId="p1" />
</div> </div>
</>, </>,
); );
@@ -70,7 +75,7 @@ beforeEach(() => {
useAppState.setState({ notesByProject: {}, notesLoading: {}, toasts: [] }); 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 () => { it("shows an edit made in one surface in the other", async () => {
const { tab, dock } = renderBothSurfaces(); const { tab, dock } = renderBothSurfaces();
await waitFor(() => expect(tab().getByLabelText("Note body")).toHaveValue("one")); 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(); const { tab, dock } = renderBothSurfaces();
await waitFor(() => expect(tab().getByLabelText("Note body")).toHaveValue("one")); 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(() => await waitFor(() =>
expect(tab().getAllByRole("button", { name: /untitled note/i })).toHaveLength(1), expect(tab().getAllByRole("button", { name: /untitled note/i })).toHaveLength(1),
+6 -43
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useMemo, useState } from "react";
import { useNotes } from "../../hooks/useNotes"; import { useNotes } from "../../hooks/useNotes";
import { useNoteDraft } from "./useNoteDraft";
import NoteEditor from "./NoteEditor"; import NoteEditor from "./NoteEditor";
import Button from "../ui/Button"; import Button from "../ui/Button";
import SaveIndicator from "../ui/SaveIndicator"; import SaveIndicator from "../ui/SaveIndicator";
@@ -34,54 +35,16 @@ export default function NotesPanel({ projectId }: Props) {
const { notes, loading, saveState, createNote, saveNote, deleteNote } = const { notes, loading, saveState, createNote, saveNote, deleteNote } =
useNotes(projectId); useNotes(projectId);
const [selectedId, setSelectedId] = useState<string | null>(null); const [selectedId, setSelectedId] = useState<string | null>(null);
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
const selected = useMemo( const selected = useMemo(
() => notes.find((n) => n.id === selectedId) ?? notes[0] ?? null, () => notes.find((n) => n.id === selectedId) ?? notes[0] ?? null,
[notes, selectedId], [notes, selectedId],
); );
// What was last copied out of the store into the draft fields. The draft is const { title, body, setTitle, setBody, commit } = useNoteDraft(
// "untouched" exactly while it still matches this, which is how an edit made selected,
// somewhere else can be shown without ever discarding something half-typed. saveNote,
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 onCreate = async () => { const onCreate = async () => {
const note = await createNote(); const note = await createNote();
@@ -39,23 +39,55 @@ beforeEach(() => {
}); });
describe("SendToAgentButton", () => { 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" />); 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" })]; sessions = [session({ projectId: "other" })];
render(<SendToAgentButton projectId="p1" body="hello" />); 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 // `bash -l`'s readline has no binding for ESC+CR and just bells, so a
// shell is never a target. // shell is never a target.
sessions = [session({ sessionType: "bash" })]; sessions = [session({ sessionType: "bash" })];
render(<SendToAgentButton projectId="p1" body="hello" />); 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 () => { 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", () => { it("does nothing for an empty note", () => {
sessions = [session()]; sessions = [session()];
render(<SendToAgentButton projectId="p1" body=" " />); 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"));
}); });
}); });
+29 -5
View File
@@ -9,6 +9,14 @@ import Button from "../ui/Button";
interface Props { interface Props {
projectId: string; projectId: string;
body: 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 * 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`). * 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 { sessions, sendInput } = useTerminal();
const { projects, setActiveTabKey, pushToast } = useAppState( const { projects, setActiveTabKey, pushToast } = useAppState(
useShallow((s) => ({ useShallow((s) => ({
@@ -43,7 +56,7 @@ export default function SendToAgentButton({ projectId, body }: Props) {
const project = projects.find((p) => p.id === projectId); const project = projects.find((p) => p.id === projectId);
const hasBody = body.trim().length > 0; 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. // Same dismissal contract as `ui/OverflowMenu` and the tab context menu.
useEffect(() => { useEffect(() => {
@@ -102,10 +115,19 @@ export default function SendToAgentButton({ projectId, body }: Props) {
: "Put this note into the agent's prompt (you press Enter)"; : "Put this note into the agent's prompt (you press Enter)";
return ( return (
<div ref={rootRef} className="relative inline-block"> <div
ref={rootRef}
className={`relative ${fullWidth ? "block w-full" : "inline-block"}`}
>
<Button <Button
variant="secondary" variant="secondary"
disabled={disabled} size={fullWidth ? "md" : "sm"}
className={fullWidth ? "w-full" : ""}
// Not `disabled`: every one of these reasons is information, and
// `disabled` takes the button — reason and all — out of the
// accessibility tree. `Button` guards the click for us.
unavailable={unavailable}
unavailableReason={title}
onClick={onClick} onClick={onClick}
aria-haspopup={targets.length > 1 ? "menu" : undefined} aria-haspopup={targets.length > 1 ? "menu" : undefined}
aria-expanded={targets.length > 1 ? menuOpen : undefined} aria-expanded={targets.length > 1 ? menuOpen : undefined}
@@ -116,7 +138,9 @@ export default function SendToAgentButton({ projectId, body }: Props) {
{menuOpen && targets.length > 1 && ( {menuOpen && targets.length > 1 && (
<div <div
role="menu" role="menu"
className="absolute right-0 z-40 mt-1 min-w-[12rem] py-1 bg-[var(--bg-overlay)] border border-[var(--border-color)] rounded-[var(--radius-panel)] text-xs" className={`absolute right-0 z-40 min-w-[12rem] py-1 bg-[var(--bg-overlay)] border border-[var(--border-color)] rounded-[var(--radius-panel)] text-xs ${
dropUp ? "bottom-full mb-1" : "mt-1"
}`}
style={{ boxShadow: "var(--shadow-overlay)" }} style={{ boxShadow: "var(--shadow-overlay)" }}
> >
{targets.map((s) => ( {targets.map((s) => (
+61
View File
@@ -0,0 +1,61 @@
import { useEffect, useRef, useState } from "react";
import type { Note } from "../../lib/types";
/**
* Draft text for the note being edited, committed when a field loses focus.
*
* This is the half the dock and the tab must never disagree on, so it lives
* here rather than in either layout. The two surfaces differ in how they show
* notes; they must not differ in when a keystroke becomes a save.
*
* The draft is "untouched" exactly while it still matches what was last copied
* out of the store, which is what lets an edit made on the *other* surface
* reach this one's editor without ever discarding half-typed text.
*/
export function useNoteDraft(
selected: Note | null,
saveNote: (note: Note) => Promise<unknown>,
) {
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
const seeded = useRef<{ id: string | null; title: string; body: string }>({
id: null,
title: "",
body: "",
});
// Re-seed on a change of note, and on a change to the *stored* text of the
// note already open — the second case is the dock and the tab showing one
// project at once.
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 });
};
return { title, body, setTitle, setBody, commit };
}