Compare commits
7
Commits
0f3fff92f4
...
v0.4.19
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc9cdd1760 | ||
|
|
c16f0d5b70 | ||
|
|
3239057f8f | ||
|
|
23364f412e | ||
|
|
b24807bd5f | ||
|
|
1eb91a35eb | ||
|
|
aa0a574091 |
@@ -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>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -11,14 +11,22 @@ vi.mock("../../hooks/useTerminal", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const setActiveTabKey = vi.fn();
|
const setActiveTabKey = vi.fn();
|
||||||
|
const requestTerminalFocus = vi.fn();
|
||||||
const pushToast = vi.fn();
|
const pushToast = vi.fn();
|
||||||
let projects: Project[] = [];
|
let projects: Project[] = [];
|
||||||
|
|
||||||
vi.mock("../../store/appState", () => ({
|
vi.mock("../../store/appState", () => ({
|
||||||
useAppState: Object.assign(
|
useAppState: Object.assign(
|
||||||
(selector: (s: unknown) => unknown) =>
|
(selector: (s: unknown) => unknown) =>
|
||||||
selector({ projects, setActiveTabKey, pushToast }),
|
selector({ projects, setActiveTabKey, requestTerminalFocus, pushToast }),
|
||||||
{ getState: () => ({ projects, setActiveTabKey, pushToast }) },
|
{
|
||||||
|
getState: () => ({
|
||||||
|
projects,
|
||||||
|
setActiveTabKey,
|
||||||
|
requestTerminalFocus,
|
||||||
|
pushToast,
|
||||||
|
}),
|
||||||
|
},
|
||||||
),
|
),
|
||||||
terminalTabKey: (id: string) => `term:${id}`,
|
terminalTabKey: (id: string) => `term:${id}`,
|
||||||
}));
|
}));
|
||||||
@@ -39,23 +47,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 +140,52 @@ 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"));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Switching to the tab is not enough. When the dock is open beside the
|
||||||
|
// terminal it sends to, that terminal is already the active tab, so
|
||||||
|
// `setActiveTabKey` changes nothing and no effect re-runs — leaving focus on
|
||||||
|
// this button, one click short of the Enter the user came to press.
|
||||||
|
it("hands focus to the terminal so the next keystroke is Enter", async () => {
|
||||||
|
sessions = [session()];
|
||||||
|
render(<SendToAgentButton projectId="p1" body="hello" />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /send to agent/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(requestTerminalFocus).toHaveBeenCalledWith("s1"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves focus alone when the send failed", async () => {
|
||||||
|
sessions = [session()];
|
||||||
|
sendInput.mockRejectedValueOnce(new Error("pty gone"));
|
||||||
|
render(<SendToAgentButton projectId="p1" body="hello" />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /send to agent/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(pushToast).toHaveBeenCalled());
|
||||||
|
expect(requestTerminalFocus).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("focuses the session picked from the menu, not the first one", async () => {
|
||||||
|
sessions = [session(), session({ id: "s2", sessionName: "review" })];
|
||||||
|
render(<SendToAgentButton projectId="p1" body="hello" />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /send to agent/i }));
|
||||||
|
fireEvent.click(await screen.findByRole("menuitem", { name: "review" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(requestTerminalFocus).toHaveBeenCalledWith("s2"));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,15 +29,22 @@ 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, requestTerminalFocus, pushToast } =
|
||||||
useShallow((s) => ({
|
useAppState(
|
||||||
projects: s.projects,
|
useShallow((s) => ({
|
||||||
setActiveTabKey: s.setActiveTabKey,
|
projects: s.projects,
|
||||||
pushToast: s.pushToast,
|
setActiveTabKey: s.setActiveTabKey,
|
||||||
})),
|
requestTerminalFocus: s.requestTerminalFocus,
|
||||||
);
|
pushToast: s.pushToast,
|
||||||
|
})),
|
||||||
|
);
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -43,7 +58,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(() => {
|
||||||
@@ -73,6 +88,11 @@ export default function SendToAgentButton({ projectId, body }: Props) {
|
|||||||
// A courtesy, not part of the send: if the tab cannot be focused the
|
// A courtesy, not part of the send: if the tab cannot be focused the
|
||||||
// text still went.
|
// text still went.
|
||||||
setActiveTabKey(terminalTabKey(sessionId));
|
setActiveTabKey(terminalTabKey(sessionId));
|
||||||
|
// Switching tabs is not the same as taking focus, and when the dock is
|
||||||
|
// open beside the terminal it just sent to, that tab is already the
|
||||||
|
// active one — so nothing above moves the caret off this button. The
|
||||||
|
// note is sitting in the prompt waiting for Enter; put the user there.
|
||||||
|
requestTerminalFocus(sessionId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
pushToast({
|
pushToast({
|
||||||
kind: "error",
|
kind: "error",
|
||||||
@@ -81,7 +101,7 @@ export default function SendToAgentButton({ projectId, body }: Props) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[body, sendInput, setActiveTabKey, pushToast],
|
[body, sendInput, setActiveTabKey, requestTerminalFocus, pushToast],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onClick = useCallback(() => {
|
const onClick = useCallback(() => {
|
||||||
@@ -102,10 +122,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 +145,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) => (
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
||||||
|
import AddProjectDialog from "./AddProjectDialog";
|
||||||
|
|
||||||
|
const add = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useProjects", () => ({
|
||||||
|
useProjects: () => ({ add }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@tauri-apps/plugin-dialog", () => ({
|
||||||
|
open: vi.fn(async () => null),
|
||||||
|
}));
|
||||||
|
|
||||||
|
/** A promise whose resolution this test controls, so `loading` can be held open. */
|
||||||
|
function deferred() {
|
||||||
|
let resolve!: (v: unknown) => void;
|
||||||
|
const promise = new Promise((r) => {
|
||||||
|
resolve = r;
|
||||||
|
});
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillValidForm() {
|
||||||
|
fireEvent.change(screen.getByLabelText("Project name"), {
|
||||||
|
target: { value: "my-project" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText("Folder 1 host path"), {
|
||||||
|
target: { value: "/home/user/my-project" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitButton() {
|
||||||
|
return screen.getByRole("button", { name: /Add Project|Adding/ });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("AddProjectDialog", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds the project with the name and folder entered", async () => {
|
||||||
|
add.mockResolvedValue({ id: "p1" });
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(<AddProjectDialog onClose={onClose} />);
|
||||||
|
fillValidForm();
|
||||||
|
fireEvent.click(submitButton());
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(add).toHaveBeenCalledWith("my-project", [
|
||||||
|
{ host_path: "/home/user/my-project", mount_name: "my-project" },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the submit button announced, and explains why, while adding", async () => {
|
||||||
|
const { promise, resolve } = deferred();
|
||||||
|
add.mockReturnValue(promise);
|
||||||
|
render(<AddProjectDialog onClose={vi.fn()} />);
|
||||||
|
fillValidForm();
|
||||||
|
fireEvent.click(submitButton());
|
||||||
|
|
||||||
|
// Native `disabled` would remove the button from the accessibility tree
|
||||||
|
// exactly when it has something to say.
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(submitButton()).toHaveAttribute("aria-disabled", "true"),
|
||||||
|
);
|
||||||
|
expect(submitButton()).not.toBeDisabled();
|
||||||
|
expect(submitButton()).toHaveAccessibleDescription(/being added/i);
|
||||||
|
|
||||||
|
await act(async () => resolve({ id: "p1" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores clicks and Enter/Space on the submit button while adding", async () => {
|
||||||
|
const { promise, resolve } = deferred();
|
||||||
|
add.mockReturnValue(promise);
|
||||||
|
render(<AddProjectDialog onClose={vi.fn()} />);
|
||||||
|
fillValidForm();
|
||||||
|
fireEvent.click(submitButton());
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(submitButton()).toHaveAttribute("aria-disabled", "true"),
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(submitButton());
|
||||||
|
fireEvent.keyDown(submitButton(), { key: "Enter" });
|
||||||
|
fireEvent.keyDown(submitButton(), { key: " " });
|
||||||
|
expect(add).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await act(async () => resolve({ id: "p1" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a form submit raised from elsewhere while adding", async () => {
|
||||||
|
const { promise, resolve } = deferred();
|
||||||
|
add.mockReturnValue(promise);
|
||||||
|
render(<AddProjectDialog onClose={vi.fn()} />);
|
||||||
|
fillValidForm();
|
||||||
|
fireEvent.click(submitButton());
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(submitButton()).toHaveAttribute("aria-disabled", "true"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Enter in a text field submits a form regardless of the submit button's
|
||||||
|
// state, so the handler has to guard itself too.
|
||||||
|
// Modal portals to document.body, so the form is not under `container`.
|
||||||
|
const form = document.querySelector("form");
|
||||||
|
expect(form).not.toBeNull();
|
||||||
|
fireEvent.submit(form!);
|
||||||
|
expect(add).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await act(async () => resolve({ id: "p1" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the submit button plainly available when idle", () => {
|
||||||
|
render(<AddProjectDialog onClose={vi.fn()} />);
|
||||||
|
expect(submitButton()).not.toHaveAttribute("aria-disabled");
|
||||||
|
expect(submitButton()).toHaveAccessibleDescription("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -55,6 +55,10 @@ export default function AddProjectDialog({ onClose }: Props) {
|
|||||||
|
|
||||||
const handleSubmit = async (e?: React.FormEvent) => {
|
const handleSubmit = async (e?: React.FormEvent) => {
|
||||||
if (e) e.preventDefault();
|
if (e) e.preventDefault();
|
||||||
|
// The submit button is `aria-disabled` rather than `disabled` while an add
|
||||||
|
// is in flight, and Enter inside a text field submits the form without
|
||||||
|
// touching the button at all. Both routes end here, so the guard does too.
|
||||||
|
if (loading) return;
|
||||||
if (!name.trim()) {
|
if (!name.trim()) {
|
||||||
setError("Project name is required");
|
setError("Project name is required");
|
||||||
return;
|
return;
|
||||||
@@ -97,7 +101,19 @@ export default function AddProjectDialog({ onClose }: Props) {
|
|||||||
<Button size="md" variant="ghost" onClick={onClose}>
|
<Button size="md" variant="ghost" onClick={onClose}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="md" variant="primary" type="submit" form={formId} disabled={loading}>
|
<Button
|
||||||
|
size="md"
|
||||||
|
variant="primary"
|
||||||
|
type="submit"
|
||||||
|
form={formId}
|
||||||
|
unavailable={loading}
|
||||||
|
unavailableReason="The project is being added. Wait for it to finish."
|
||||||
|
title={
|
||||||
|
loading
|
||||||
|
? "The project is being added. Wait for it to finish."
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
{loading ? "Adding…" : "Add Project"}
|
{loading ? "Adding…" : "Add Project"}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -122,14 +122,6 @@ describe("ProjectRow", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("only allows opening a terminal while the container runs", () => {
|
it("only allows opening a terminal while the container runs", () => {
|
||||||
const { unmount } = render(<ProjectRow project={baseProject} />);
|
|
||||||
expect(
|
|
||||||
screen.getByRole("button", {
|
|
||||||
name: "Open a Claude terminal for Test Project",
|
|
||||||
}),
|
|
||||||
).toBeDisabled();
|
|
||||||
unmount();
|
|
||||||
|
|
||||||
render(<ProjectRow project={{ ...baseProject, status: "running" }} />);
|
render(<ProjectRow project={{ ...baseProject, status: "running" }} />);
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
screen.getByRole("button", {
|
screen.getByRole("button", {
|
||||||
@@ -139,6 +131,38 @@ describe("ProjectRow", () => {
|
|||||||
expect(mockOpenClaudeTerminal).toHaveBeenCalled();
|
expect(mockOpenClaudeTerminal).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the terminal button announced, and explains why, while stopped", () => {
|
||||||
|
render(<ProjectRow project={baseProject} />);
|
||||||
|
const button = screen.getByRole("button", {
|
||||||
|
name: "Open a Claude terminal for Test Project",
|
||||||
|
});
|
||||||
|
// Native `disabled` would drop the button out of the accessibility tree
|
||||||
|
// and out of the tab order, taking the reason with it.
|
||||||
|
expect(button).not.toBeDisabled();
|
||||||
|
expect(button).toHaveAttribute("aria-disabled", "true");
|
||||||
|
expect(button).toHaveAccessibleDescription(/is not running/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores clicks and Enter/Space on the terminal button while stopped", () => {
|
||||||
|
render(<ProjectRow project={baseProject} />);
|
||||||
|
const button = screen.getByRole("button", {
|
||||||
|
name: "Open a Claude terminal for Test Project",
|
||||||
|
});
|
||||||
|
fireEvent.click(button);
|
||||||
|
fireEvent.keyDown(button, { key: "Enter" });
|
||||||
|
fireEvent.keyDown(button, { key: " " });
|
||||||
|
expect(mockOpenClaudeTerminal).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops aria-disabled once the container is running", () => {
|
||||||
|
render(<ProjectRow project={{ ...baseProject, status: "running" }} />);
|
||||||
|
const button = screen.getByRole("button", {
|
||||||
|
name: "Open a Claude terminal for Test Project",
|
||||||
|
});
|
||||||
|
expect(button).not.toHaveAttribute("aria-disabled");
|
||||||
|
expect(button).not.toHaveAccessibleDescription(/is not running/i);
|
||||||
|
});
|
||||||
|
|
||||||
it("shows container progress inline rather than in a blocking modal", () => {
|
it("shows container progress inline rather than in a blocking modal", () => {
|
||||||
setStore({ containerProgress: { "test-1": "Pulling image…" } });
|
setStore({ containerProgress: { "test-1": "Pulling image…" } });
|
||||||
render(<ProjectRow project={{ ...baseProject, status: "starting" }} />);
|
render(<ProjectRow project={{ ...baseProject, status: "starting" }} />);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { Project } from "../../lib/types";
|
|||||||
import { useAppState, homeTabKey } from "../../store/appState";
|
import { useAppState, homeTabKey } from "../../store/appState";
|
||||||
import { useProjectActions } from "../../hooks/useProjectActions";
|
import { useProjectActions } from "../../hooks/useProjectActions";
|
||||||
import { ProjectStatusIndicator } from "../ui/StatusIndicator";
|
import { ProjectStatusIndicator } from "../ui/StatusIndicator";
|
||||||
|
import { useUnavailable } from "../ui/unavailable";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
project: Project;
|
project: Project;
|
||||||
@@ -31,6 +32,15 @@ export default function ProjectRow({ project }: Props) {
|
|||||||
const isTransitioning =
|
const isTransitioning =
|
||||||
project.status === "starting" || project.status === "stopping";
|
project.status === "starting" || project.status === "stopping";
|
||||||
|
|
||||||
|
// A terminal needs a running container. Saying so out loud beats a `disabled`
|
||||||
|
// attribute that hides the button — and the reason — from anyone not using a
|
||||||
|
// mouse and eyes.
|
||||||
|
const terminal = useUnavailable({
|
||||||
|
unavailable: !isRunning,
|
||||||
|
reason: `${project.name} is not running. Start it to open a terminal.`,
|
||||||
|
onClick: () => openClaudeTerminal(),
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`group relative px-2 py-1.5 rounded-[var(--radius-control)] transition-colors min-w-0 overflow-hidden ${
|
className={`group relative px-2 py-1.5 rounded-[var(--radius-control)] transition-colors min-w-0 overflow-hidden ${
|
||||||
@@ -113,11 +123,14 @@ export default function ProjectRow({ project }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!isRunning}
|
{...terminal.controlProps}
|
||||||
onClick={() => openClaudeTerminal()}
|
title={
|
||||||
title={`Open a Claude terminal for ${project.name}`}
|
isRunning
|
||||||
|
? `Open a Claude terminal for ${project.name}`
|
||||||
|
: `${project.name} is not running. Start it to open a terminal.`
|
||||||
|
}
|
||||||
aria-label={`Open a Claude terminal for ${project.name}`}
|
aria-label={`Open a Claude terminal for ${project.name}`}
|
||||||
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] disabled:text-[var(--text-disabled)] transition-colors"
|
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] disabled:text-[var(--text-disabled)] aria-disabled:text-[var(--text-disabled)] aria-disabled:hover:text-[var(--text-disabled)] aria-disabled:hover:bg-transparent aria-disabled:cursor-not-allowed transition-colors"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="w-3.5 h-3.5"
|
className="w-3.5 h-3.5"
|
||||||
@@ -134,6 +147,7 @@ export default function ProjectRow({ project }: Props) {
|
|||||||
<line x1="13" y1="15" x2="17" y2="15" />
|
<line x1="13" y1="15" x2="17" y2="15" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
{terminal.reasonNode}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -538,3 +538,59 @@ describe("TerminalView — reaching the URL prompt without a mouse", () => {
|
|||||||
expect(document.activeElement).toBe(before);
|
expect(document.activeElement).toBe(before);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("TerminalView — focus on request", () => {
|
||||||
|
/** Mount, then deliberately give focus away, so what the assertions below
|
||||||
|
* observe is the *request* taking effect and never the focus `active`
|
||||||
|
* already grants on mount. That distinction is the whole point: the notes
|
||||||
|
* dock sends to a terminal whose tab is already active, where nothing
|
||||||
|
* changes and no `active` effect re-runs. */
|
||||||
|
async function mountAndBlur() {
|
||||||
|
const view = mountSession("claude");
|
||||||
|
await act(async () => {});
|
||||||
|
const elsewhere = document.createElement("button");
|
||||||
|
document.body.appendChild(elsewhere);
|
||||||
|
elsewhere.focus();
|
||||||
|
expect(document.activeElement).toBe(elsewhere);
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("focuses the terminal named by the request", async () => {
|
||||||
|
const view = await mountAndBlur();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
useAppState.getState().requestTerminalFocus("s1");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(document.activeElement).toBe(helperTextarea(view.container));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a request meant for another session", async () => {
|
||||||
|
const view = await mountAndBlur();
|
||||||
|
const before = document.activeElement;
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
useAppState.getState().requestTerminalFocus("s2");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(document.activeElement).toBe(before);
|
||||||
|
expect(document.activeElement).not.toBe(helperTextarea(view.container));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the request, so a second send focuses again", async () => {
|
||||||
|
const view = await mountAndBlur();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
useAppState.getState().requestTerminalFocus("s1");
|
||||||
|
});
|
||||||
|
expect(useAppState.getState().pendingTerminalFocus).toBeNull();
|
||||||
|
|
||||||
|
const elsewhere = document.querySelector("button");
|
||||||
|
(elsewhere as HTMLButtonElement).focus();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
useAppState.getState().requestTerminalFocus("s1");
|
||||||
|
});
|
||||||
|
expect(document.activeElement).toBe(helperTextarea(view.container));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -731,6 +731,21 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
}
|
}
|
||||||
}, [active, gpuRenderingSetting]);
|
}, [active, gpuRenderingSetting]);
|
||||||
|
|
||||||
|
// Focus on demand, for the caller that cannot rely on the effect above.
|
||||||
|
// That one keys off `active`, so it covers switching *to* a terminal and
|
||||||
|
// nothing else — and the notes dock sends to the terminal already on screen,
|
||||||
|
// where `active` never changes. Consumed once and cleared, so asking twice
|
||||||
|
// for the same terminal works.
|
||||||
|
const pendingTerminalFocus = useAppState((s) => s.pendingTerminalFocus);
|
||||||
|
const clearPendingTerminalFocus = useAppState(
|
||||||
|
(s) => s.clearPendingTerminalFocus,
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
if (pendingTerminalFocus !== sessionId) return;
|
||||||
|
termRef.current?.focus();
|
||||||
|
clearPendingTerminalFocus();
|
||||||
|
}, [pendingTerminalFocus, sessionId, clearPendingTerminalFocus]);
|
||||||
|
|
||||||
// Auto-dismiss toast after 30 seconds — unless the user is standing in it.
|
// Auto-dismiss toast after 30 seconds — unless the user is standing in it.
|
||||||
// A keyboard user who has just jumped into the toast is mid-decision, and
|
// A keyboard user who has just jumped into the toast is mid-decision, and
|
||||||
// pulling it out from under them costs them the only route to finishing a
|
// pulling it out from under them costs them the only route to finishing a
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import Button from "./Button";
|
||||||
|
|
||||||
|
const onClick = vi.fn();
|
||||||
|
const onKeyDown = vi.fn();
|
||||||
|
|
||||||
|
describe("Button", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still supports the native disabled attribute", () => {
|
||||||
|
render(
|
||||||
|
<Button disabled onClick={onClick}>
|
||||||
|
Save
|
||||||
|
</Button>,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays in the accessibility tree when unavailable, and says why", () => {
|
||||||
|
render(
|
||||||
|
<Button unavailable unavailableReason="Stop the container first.">
|
||||||
|
Save
|
||||||
|
</Button>,
|
||||||
|
);
|
||||||
|
const button = screen.getByRole("button", { name: "Save" });
|
||||||
|
expect(button).not.toBeDisabled();
|
||||||
|
expect(button).toHaveAttribute("aria-disabled", "true");
|
||||||
|
expect(button).toHaveAccessibleDescription("Stop the container first.");
|
||||||
|
// The reason is a description, not part of the name.
|
||||||
|
expect(button).toHaveAccessibleName("Save");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("guards clicks and Enter/Space while unavailable", () => {
|
||||||
|
render(
|
||||||
|
<Button unavailable unavailableReason="Stop the container first." onClick={onClick}>
|
||||||
|
Save
|
||||||
|
</Button>,
|
||||||
|
);
|
||||||
|
const button = screen.getByRole("button", { name: "Save" });
|
||||||
|
fireEvent.click(button);
|
||||||
|
fireEvent.keyDown(button, { key: "Enter" });
|
||||||
|
fireEvent.keyDown(button, { key: " " });
|
||||||
|
expect(onClick).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still forwards keys that are not activation keys", () => {
|
||||||
|
render(
|
||||||
|
<Button
|
||||||
|
unavailable
|
||||||
|
unavailableReason="Stop the container first."
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>,
|
||||||
|
);
|
||||||
|
fireEvent.keyDown(screen.getByRole("button", { name: "Save" }), {
|
||||||
|
key: "Escape",
|
||||||
|
});
|
||||||
|
expect(onKeyDown).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("behaves like an ordinary button when available", () => {
|
||||||
|
render(
|
||||||
|
<Button unavailable={false} unavailableReason="Stop the container first." onClick={onClick}>
|
||||||
|
Save
|
||||||
|
</Button>,
|
||||||
|
);
|
||||||
|
const button = screen.getByRole("button", { name: "Save" });
|
||||||
|
expect(button).not.toHaveAttribute("aria-disabled");
|
||||||
|
expect(button).toHaveAccessibleDescription("");
|
||||||
|
fireEvent.click(button);
|
||||||
|
expect(onClick).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||||
|
import { useUnavailable } from "./unavailable";
|
||||||
|
|
||||||
export type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
|
export type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
|
||||||
export type ButtonSize = "sm" | "md";
|
export type ButtonSize = "sm" | "md";
|
||||||
@@ -7,22 +8,37 @@ interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|||||||
variant?: ButtonVariant;
|
variant?: ButtonVariant;
|
||||||
size?: ButtonSize;
|
size?: ButtonSize;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
/**
|
||||||
|
* Unavailable, but still announced. Renders `aria-disabled` and wires
|
||||||
|
* `unavailableReason` to `aria-describedby` instead of using the native
|
||||||
|
* `disabled` attribute, which would take the button out of the tab order and
|
||||||
|
* out of the accessibility tree — reason and all. Clicks and Enter/Space are
|
||||||
|
* guarded for you. Prefer this over `disabled` whenever there is a reason
|
||||||
|
* worth telling the user.
|
||||||
|
*/
|
||||||
|
unavailable?: boolean;
|
||||||
|
/** Why the button cannot be used. Required for `unavailable` to say anything. */
|
||||||
|
unavailableReason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Real buttons with visible bounds and a ≥24px hit target.
|
* Real buttons with visible bounds and a ≥24px hit target.
|
||||||
* Filled variants use the *-emphasis tokens so white text clears WCAG AA;
|
* Filled variants use the *-emphasis tokens so white text clears WCAG AA;
|
||||||
* `--accent` stays reserved for foreground/link use.
|
* `--accent` stays reserved for foreground/link use.
|
||||||
|
*
|
||||||
|
* The `aria-disabled:` class mirrors below exist because Tailwind's
|
||||||
|
* `disabled:` variant only matches the native attribute, which `unavailable`
|
||||||
|
* deliberately does not set. Keep the two lists in step.
|
||||||
*/
|
*/
|
||||||
const VARIANTS: Record<ButtonVariant, string> = {
|
const VARIANTS: Record<ButtonVariant, string> = {
|
||||||
primary:
|
primary:
|
||||||
"bg-[var(--accent-emphasis)] text-white border border-transparent hover:bg-[var(--accent-emphasis-hover)] disabled:bg-[var(--bg-tertiary)] disabled:text-[var(--text-disabled)] disabled:border-[var(--border-color)]",
|
"bg-[var(--accent-emphasis)] text-white border border-transparent hover:bg-[var(--accent-emphasis-hover)] disabled:bg-[var(--bg-tertiary)] disabled:text-[var(--text-disabled)] disabled:border-[var(--border-color)] aria-disabled:bg-[var(--bg-tertiary)] aria-disabled:text-[var(--text-disabled)] aria-disabled:border-[var(--border-color)] aria-disabled:hover:bg-[var(--bg-tertiary)]",
|
||||||
secondary:
|
secondary:
|
||||||
"bg-[var(--bg-tertiary)] text-[var(--text-primary)] border border-[var(--border-color)] hover:bg-[var(--border-color)] disabled:text-[var(--text-disabled)] disabled:hover:bg-[var(--bg-tertiary)]",
|
"bg-[var(--bg-tertiary)] text-[var(--text-primary)] border border-[var(--border-color)] hover:bg-[var(--border-color)] disabled:text-[var(--text-disabled)] disabled:hover:bg-[var(--bg-tertiary)] aria-disabled:text-[var(--text-disabled)] aria-disabled:hover:bg-[var(--bg-tertiary)]",
|
||||||
danger:
|
danger:
|
||||||
"bg-transparent text-[var(--error)] border border-[var(--error)]/40 hover:bg-[var(--error-muted)] disabled:text-[var(--text-disabled)] disabled:border-[var(--border-color)] disabled:hover:bg-transparent",
|
"bg-transparent text-[var(--error)] border border-[var(--error)]/40 hover:bg-[var(--error-muted)] disabled:text-[var(--text-disabled)] disabled:border-[var(--border-color)] disabled:hover:bg-transparent aria-disabled:text-[var(--text-disabled)] aria-disabled:border-[var(--border-color)] aria-disabled:hover:bg-transparent",
|
||||||
ghost:
|
ghost:
|
||||||
"bg-transparent text-[var(--text-secondary)] border border-transparent hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] disabled:text-[var(--text-disabled)] disabled:hover:bg-transparent",
|
"bg-transparent text-[var(--text-secondary)] border border-transparent hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] disabled:text-[var(--text-disabled)] disabled:hover:bg-transparent aria-disabled:text-[var(--text-disabled)] aria-disabled:hover:text-[var(--text-disabled)] aria-disabled:hover:bg-transparent",
|
||||||
};
|
};
|
||||||
|
|
||||||
const SIZES: Record<ButtonSize, string> = {
|
const SIZES: Record<ButtonSize, string> = {
|
||||||
@@ -35,16 +51,30 @@ export default function Button({
|
|||||||
size = "sm",
|
size = "sm",
|
||||||
className = "",
|
className = "",
|
||||||
type = "button",
|
type = "button",
|
||||||
|
unavailable = false,
|
||||||
|
unavailableReason = "",
|
||||||
children,
|
children,
|
||||||
...rest
|
...rest
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const { controlProps, reasonNode } = useUnavailable({
|
||||||
|
unavailable,
|
||||||
|
reason: unavailableReason,
|
||||||
|
onClick: rest.onClick,
|
||||||
|
onKeyDown: rest.onKeyDown,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<>
|
||||||
type={type}
|
<button
|
||||||
{...rest}
|
type={type}
|
||||||
className={`inline-flex items-center justify-center whitespace-nowrap rounded-[var(--radius-control)] font-medium transition-colors disabled:cursor-not-allowed ${SIZES[size]} ${VARIANTS[variant]} ${className}`}
|
{...rest}
|
||||||
>
|
{...controlProps}
|
||||||
{children}
|
className={`inline-flex items-center justify-center whitespace-nowrap rounded-[var(--radius-control)] font-medium transition-colors disabled:cursor-not-allowed aria-disabled:cursor-not-allowed ${SIZES[size]} ${VARIANTS[variant]} ${className}`}
|
||||||
</button>
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
{/* Outside the button: inside, the reason would join its accessible name. */}
|
||||||
|
{reasonNode}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import {
|
||||||
|
useId,
|
||||||
|
type KeyboardEventHandler,
|
||||||
|
type MouseEventHandler,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
/** Keys a native `<button>` turns into a click. */
|
||||||
|
const ACTIVATION_KEYS = new Set([" ", "Spacebar", "Enter"]);
|
||||||
|
|
||||||
|
export interface UnavailableControlProps {
|
||||||
|
"aria-disabled"?: true;
|
||||||
|
"aria-describedby"?: string;
|
||||||
|
onClick?: MouseEventHandler<HTMLButtonElement>;
|
||||||
|
onKeyDown?: KeyboardEventHandler<HTMLButtonElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnavailableControl {
|
||||||
|
/** Spread onto the control. Carries the guarded handlers. */
|
||||||
|
controlProps: UnavailableControlProps;
|
||||||
|
/**
|
||||||
|
* Render as a *sibling* of the control — inside it the reason would be
|
||||||
|
* appended to the accessible name instead of the description.
|
||||||
|
*/
|
||||||
|
reasonNode: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes a control unavailable without hiding it from assistive technology.
|
||||||
|
*
|
||||||
|
* `disabled` takes an element out of the tab order *and* out of the
|
||||||
|
* accessibility tree, so the `title` explaining why it cannot be used is
|
||||||
|
* announced to nobody and shown only to a sighted user with a mouse. That is
|
||||||
|
* backwards: the people who most need the reason are the ones who never get
|
||||||
|
* it. `aria-disabled` keeps the control focusable and announced, and
|
||||||
|
* `aria-describedby` hands over the reason.
|
||||||
|
*
|
||||||
|
* The catch is that `aria-disabled` is advisory — it does not block clicks or
|
||||||
|
* Enter/Space the way `disabled` does. This hook therefore returns the guards
|
||||||
|
* along with the attributes, so a call site cannot take the announcement
|
||||||
|
* without the guard. Handlers that a form can reach without going through the
|
||||||
|
* control (Enter inside a text field submits the form) still have to guard
|
||||||
|
* themselves.
|
||||||
|
*/
|
||||||
|
export function useUnavailable({
|
||||||
|
unavailable,
|
||||||
|
reason,
|
||||||
|
onClick,
|
||||||
|
onKeyDown,
|
||||||
|
}: {
|
||||||
|
unavailable: boolean;
|
||||||
|
reason: string;
|
||||||
|
onClick?: MouseEventHandler<HTMLButtonElement>;
|
||||||
|
onKeyDown?: KeyboardEventHandler<HTMLButtonElement>;
|
||||||
|
}): UnavailableControl {
|
||||||
|
const reasonId = `${useId()}unavailable`;
|
||||||
|
|
||||||
|
if (!unavailable) {
|
||||||
|
return { controlProps: { onClick, onKeyDown }, reasonNode: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
controlProps: {
|
||||||
|
"aria-disabled": true,
|
||||||
|
"aria-describedby": reasonId,
|
||||||
|
onClick: (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
},
|
||||||
|
onKeyDown: (e) => {
|
||||||
|
if (!ACTIVATION_KEYS.has(e.key)) {
|
||||||
|
onKeyDown?.(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Suppress the default action before it can become a click, submit a
|
||||||
|
// form, or scroll the page.
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
reasonNode: (
|
||||||
|
<span id={reasonId} className="sr-only">
|
||||||
|
{reason}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -112,3 +112,27 @@ describe("toasts", () => {
|
|||||||
expect(toasts()).toHaveLength(2);
|
expect(toasts()).toHaveLength(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("terminal focus requests", () => {
|
||||||
|
beforeEach(() => useAppState.setState({ pendingTerminalFocus: null }));
|
||||||
|
|
||||||
|
const pending = () => useAppState.getState().pendingTerminalFocus;
|
||||||
|
|
||||||
|
it("names the session that should take focus", () => {
|
||||||
|
useAppState.getState().requestTerminalFocus("s1");
|
||||||
|
expect(pending()).toBe("s1");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Consumed once, exactly like `pendingHomeTab`. Without the clear, the
|
||||||
|
// second send to a terminal already holding the request would set the same
|
||||||
|
// value, no state would change, and no effect would re-run — which is the
|
||||||
|
// failure this whole mechanism exists to fix.
|
||||||
|
it("is cleared once consumed, so the same terminal can be asked again", () => {
|
||||||
|
useAppState.getState().requestTerminalFocus("s1");
|
||||||
|
useAppState.getState().clearPendingTerminalFocus();
|
||||||
|
expect(pending()).toBeNull();
|
||||||
|
|
||||||
|
useAppState.getState().requestTerminalFocus("s1");
|
||||||
|
expect(pending()).toBe("s1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -144,6 +144,21 @@ interface AppState {
|
|||||||
/** Consumed once by `ProjectHome`, then cleared. */
|
/** Consumed once by `ProjectHome`, then cleared. */
|
||||||
pendingHomeTab: { projectId: string; tab: string } | null;
|
pendingHomeTab: { projectId: string; tab: string } | null;
|
||||||
clearPendingHomeTab: () => void;
|
clearPendingHomeTab: () => void;
|
||||||
|
/**
|
||||||
|
* Ask a terminal to take keyboard focus.
|
||||||
|
*
|
||||||
|
* `TerminalView` already focuses when its tab *becomes* active, which covers
|
||||||
|
* switching to a terminal. It cannot cover being asked to focus the terminal
|
||||||
|
* that is already on screen — nothing changes, so no effect re-runs — and
|
||||||
|
* that is the ordinary case for the notes dock, which sits beside the
|
||||||
|
* terminal it sends to.
|
||||||
|
*
|
||||||
|
* Consumed once and cleared, like `pendingHomeTab`: holding the id would
|
||||||
|
* make a second request for the same terminal a no-op state write.
|
||||||
|
*/
|
||||||
|
pendingTerminalFocus: string | null;
|
||||||
|
requestTerminalFocus: (sessionId: string) => void;
|
||||||
|
clearPendingTerminalFocus: () => void;
|
||||||
closeHomeTab: (projectId: string) => void;
|
closeHomeTab: (projectId: string) => void;
|
||||||
setActiveTabKey: (key: string) => void;
|
setActiveTabKey: (key: string) => void;
|
||||||
cycleTab: (delta: number) => void;
|
cycleTab: (delta: number) => void;
|
||||||
@@ -352,6 +367,9 @@ export const useAppState = create<AppState>((set) => ({
|
|||||||
}),
|
}),
|
||||||
pendingHomeTab: null,
|
pendingHomeTab: null,
|
||||||
clearPendingHomeTab: () => set({ pendingHomeTab: null }),
|
clearPendingHomeTab: () => set({ pendingHomeTab: null }),
|
||||||
|
pendingTerminalFocus: null,
|
||||||
|
requestTerminalFocus: (sessionId) => set({ pendingTerminalFocus: sessionId }),
|
||||||
|
clearPendingTerminalFocus: () => set({ pendingTerminalFocus: null }),
|
||||||
closeHomeTab: (projectId) =>
|
closeHomeTab: (projectId) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const key = homeTabKey(projectId);
|
const key = homeTabKey(projectId);
|
||||||
|
|||||||
Reference in New Issue
Block a user