Files
Triple-C/app/src/components/layout/NotesDock.test.tsx
T
shadowdaoandClaude Opus 5 3239057f8f
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
Give the dock its own compact notes layout
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
2026-09-02 12:10:00 -07:00

114 lines
3.8 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import NotesDock from "./NotesDock";
import type { Project, TerminalSession } from "../../lib/types";
vi.mock("../notes/NotesDockPanel", () => ({
default: ({ projectId }: { projectId: string }) => (
<div data-testid="panel">{`panel:${projectId}`}</div>
),
}));
let state: Record<string, unknown> = {};
vi.mock("../../store/appState", () => ({
useAppState: Object.assign(
(selector: (s: unknown) => unknown) => selector(state),
{ getState: () => state },
),
isHomeTab: (k: string) => k.startsWith("home:"),
isTerminalTab: (k: string) => k.startsWith("term:"),
tabKeyId: (k: string) => k.slice(k.indexOf(":") + 1),
// The mocked store module still needs to supply the width constants the
// dock imports from it for the separator's aria-value attributes.
NOTES_DOCK_MIN_WIDTH: 260,
NOTES_DOCK_MAX_WIDTH: 720,
}));
const session: TerminalSession = {
id: "s1",
projectId: "p9",
projectName: "api",
sessionType: "claude",
sessionName: null,
};
beforeEach(() => {
state = {
notesDockOpen: true,
setNotesDockOpen: vi.fn(),
toggleNotesDock: vi.fn(),
notesDockWidth: 352,
setNotesDockWidth: vi.fn(),
activeTabKey: null,
sessions: [session],
projects: [{ id: "p9", name: "api" } as unknown as Project],
};
});
describe("NotesDock", () => {
it("renders nothing when closed", () => {
state.notesDockOpen = false;
const { container } = render(<NotesDock />);
expect(container).toBeEmptyDOMElement();
});
it("follows a project home tab", () => {
state.activeTabKey = "home:p1";
render(<NotesDock />);
expect(screen.getByTestId("panel")).toHaveTextContent("panel:p1");
});
it("follows the project of the active terminal tab", () => {
// The dock exists to be visible while the agent runs, so a terminal tab
// must resolve to its project, not to nothing.
state.activeTabKey = "term:s1";
render(<NotesDock />);
expect(screen.getByTestId("panel")).toHaveTextContent("panel:p9");
});
it("explains itself when no project is active", () => {
state.activeTabKey = null;
render(<NotesDock />);
expect(screen.queryByTestId("panel")).not.toBeInTheDocument();
expect(screen.getByText(/open a project/i)).toBeInTheDocument();
});
it("shows nothing for a terminal whose session has gone", () => {
state.activeTabKey = "term:vanished";
render(<NotesDock />);
expect(screen.queryByTestId("panel")).not.toBeInTheDocument();
});
it("renders at the stored width", () => {
state.activeTabKey = "home:p1";
state.notesDockWidth = 420;
render(<NotesDock />);
expect(screen.getByLabelText("Notes")).toHaveStyle({ width: "420px" });
});
it("has a keyboard-reachable resize handle", () => {
// Drag is a mouse gesture; a separator that only responds to pointer
// events is unusable without one.
state.activeTabKey = "home:p1";
render(<NotesDock />);
const handle = screen.getByRole("separator", { name: /resize notes/i });
fireEvent.keyDown(handle, { key: "ArrowLeft" });
expect(state.setNotesDockWidth).toHaveBeenCalled();
});
it("widens on ArrowLeft and narrows on ArrowRight, by the exact step", () => {
// The dock sits on the right edge, so dragging or pressing left grows it
// and right shrinks it. Asserting only "was called" would pass even if
// the branches were swapped or the sign inverted.
state.activeTabKey = "home:p1";
render(<NotesDock />);
const handle = screen.getByRole("separator", { name: /resize notes/i });
fireEvent.keyDown(handle, { key: "ArrowLeft" });
expect(state.setNotesDockWidth).toHaveBeenLastCalledWith(368);
fireEvent.keyDown(handle, { key: "ArrowRight" });
expect(state.setNotesDockWidth).toHaveBeenLastCalledWith(336);
});
});