Add the Notes tab
This commit is contained in:
@@ -0,0 +1,63 @@
|
|||||||
|
import SendToAgentButton from "./SendToAgentButton";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectId: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
onTitleChange: (value: string) => void;
|
||||||
|
onBodyChange: (value: string) => void;
|
||||||
|
onCommit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Title and body, saved when a field loses focus.
|
||||||
|
*
|
||||||
|
* Plain text on purpose. There is no markdown rendering and no view/edit split,
|
||||||
|
* so there is no moment where the text on screen is not the text that would be
|
||||||
|
* sent — which is what makes "the agent gets exactly what you see" true rather
|
||||||
|
* than nearly true.
|
||||||
|
*/
|
||||||
|
export default function NoteEditor({
|
||||||
|
projectId,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
onTitleChange,
|
||||||
|
onBodyChange,
|
||||||
|
onCommit,
|
||||||
|
onDelete,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full min-h-0 gap-2 p-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<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-8 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"
|
||||||
|
/>
|
||||||
|
{/* The live editor text, not `note.body` — what is on screen is what
|
||||||
|
gets sent. */}
|
||||||
|
<SendToAgentButton projectId={projectId} body={body} />
|
||||||
|
<Button variant="danger" onClick={onDelete} aria-label="Delete note">
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={body}
|
||||||
|
onChange={(e) => onBodyChange(e.target.value)}
|
||||||
|
onBlur={onCommit}
|
||||||
|
placeholder="Reminders, gotchas, a prompt worth keeping…"
|
||||||
|
aria-label="Note body"
|
||||||
|
className="flex-1 min-h-0 w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] text-[13px] text-[var(--text-primary)] focus:border-[var(--accent)] resize-none font-mono transition-colors"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">
|
||||||
|
Notes save when a field loses focus. Sending puts the note in the agent’s
|
||||||
|
prompt — you press Enter.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
|
import NotesPanel from "./NotesPanel";
|
||||||
|
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,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./SendToAgentButton", () => ({
|
||||||
|
default: ({ body }: { body: string }) => (
|
||||||
|
<button type="button" data-testid="send">{`send:${body}`}</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();
|
||||||
|
notes = [];
|
||||||
|
loading = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("NotesPanel", () => {
|
||||||
|
it("invites the user to start when there are no notes", () => {
|
||||||
|
render(<NotesPanel projectId="p1" />);
|
||||||
|
expect(screen.getByText(/no notes yet/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists notes by title and selects the first", () => {
|
||||||
|
notes = [note(), note({ id: "n2", title: "Gotchas" })];
|
||||||
|
render(<NotesPanel projectId="p1" />);
|
||||||
|
expect(screen.getByRole("button", { name: /deploy steps/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("Note body")).toHaveValue("one\ntwo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows an untitled note under a placeholder rather than a blank row", () => {
|
||||||
|
notes = [note({ title: "" })];
|
||||||
|
render(<NotesPanel projectId="p1" />);
|
||||||
|
expect(screen.getByRole("button", { name: /untitled note/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("switches the editor when another note is selected", () => {
|
||||||
|
notes = [note(), note({ id: "n2", title: "Gotchas", body: "beware" })];
|
||||||
|
render(<NotesPanel projectId="p1" />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /gotchas/i }));
|
||||||
|
expect(screen.getByLabelText("Note body")).toHaveValue("beware");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves on blur, not on every keystroke", async () => {
|
||||||
|
notes = [note()];
|
||||||
|
render(<NotesPanel projectId="p1" />);
|
||||||
|
const body = screen.getByLabelText("Note body");
|
||||||
|
|
||||||
|
fireEvent.change(body, { target: { value: "edited" } });
|
||||||
|
expect(saveNote).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
fireEvent.blur(body);
|
||||||
|
await waitFor(() => expect(saveNote).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ id: "n1", body: "edited" }),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not save on blur when nothing changed", async () => {
|
||||||
|
// Clicking through notes to read them must not write the file.
|
||||||
|
notes = [note()];
|
||||||
|
render(<NotesPanel projectId="p1" />);
|
||||||
|
fireEvent.blur(screen.getByLabelText("Note body"));
|
||||||
|
await waitFor(() => expect(saveNote).not.toHaveBeenCalled());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hands the live editor text to the send button, not the last saved copy", () => {
|
||||||
|
// Sending what is on screen is the whole contract: no transform on the way
|
||||||
|
// out except the newline substitution.
|
||||||
|
notes = [note()];
|
||||||
|
render(<NotesPanel projectId="p1" />);
|
||||||
|
fireEvent.change(screen.getByLabelText("Note body"), { target: { value: "fresh" } });
|
||||||
|
expect(screen.getByTestId("send")).toHaveTextContent("send:fresh");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes the selected note and falls back to another", async () => {
|
||||||
|
notes = [note(), note({ id: "n2", title: "Gotchas" })];
|
||||||
|
render(<NotesPanel projectId="p1" />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /delete note/i }));
|
||||||
|
await waitFor(() => expect(deleteNote).toHaveBeenCalledWith("n1"));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useNotes } from "../../hooks/useNotes";
|
||||||
|
import NoteEditor from "./NoteEditor";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
import SaveIndicator from "../ui/SaveIndicator";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UNTITLED = "Untitled note";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The notes surface itself, shared by the Project Home tab and the dock so the
|
||||||
|
* two cannot drift into different behaviour.
|
||||||
|
*
|
||||||
|
* Master/detail: titles on the left, one editor on the right. The editor holds
|
||||||
|
* draft text locally and commits on blur, which is how every other editable
|
||||||
|
* field in the app behaves (`ClaudeInstructionsEditor`, the Config tab).
|
||||||
|
*/
|
||||||
|
export default function NotesPanel({ projectId }: Props) {
|
||||||
|
const { notes, loading, saveState, createNote, saveNote, deleteNote } =
|
||||||
|
useNotes(projectId);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [body, setBody] = useState("");
|
||||||
|
|
||||||
|
const selected = useMemo(
|
||||||
|
() => notes.find((n) => n.id === selectedId) ?? notes[0] ?? null,
|
||||||
|
[notes, selectedId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Load the selected note's stored text into the draft. Keyed on the id, not
|
||||||
|
// the note object, so a save round trip does not stomp what is being typed.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selected) {
|
||||||
|
setTitle("");
|
||||||
|
setBody("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTitle(selected.title);
|
||||||
|
setBody(selected.body);
|
||||||
|
}, [selected?.id]); // 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;
|
||||||
|
void saveNote({ ...selected, title, body });
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full min-h-0">
|
||||||
|
<div className="flex items-center justify-between gap-2 px-3 py-2 border-b border-[var(--border-color)]">
|
||||||
|
<Button variant="primary" onClick={onCreate}>
|
||||||
|
New note
|
||||||
|
</Button>
|
||||||
|
<SaveIndicator state={saveState} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{notes.length === 0 ? (
|
||||||
|
<div className="flex-1 flex items-center justify-center p-4">
|
||||||
|
<p className="text-[13px] text-[var(--text-secondary)] text-center">
|
||||||
|
No notes yet. Keep reminders here, and send any of them straight to a
|
||||||
|
running Claude session.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 min-h-0 flex">
|
||||||
|
<ul className="w-48 flex-shrink-0 overflow-y-auto border-r border-[var(--border-color)] py-1">
|
||||||
|
{notes.map((n) => (
|
||||||
|
<li key={n.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedId(n.id)}
|
||||||
|
className={`w-full text-left px-3 py-1.5 text-xs truncate transition-colors ${
|
||||||
|
selected?.id === n.id
|
||||||
|
? "bg-[var(--bg-tertiary)] text-[var(--text-primary)]"
|
||||||
|
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{n.title.trim() || UNTITLED}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{selected && (
|
||||||
|
<NoteEditor
|
||||||
|
projectId={projectId}
|
||||||
|
title={title}
|
||||||
|
body={body}
|
||||||
|
onTitleChange={setTitle}
|
||||||
|
onBodyChange={setBody}
|
||||||
|
onCommit={commit}
|
||||||
|
onDelete={() => void deleteNote(selected.id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { Project } from "../../../lib/types";
|
||||||
|
import NotesPanel from "../../notes/NotesPanel";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
project: Project;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notes as a Project Home sub-tab.
|
||||||
|
*
|
||||||
|
* The same panel the dock shows. This is the roomy view for writing; the dock
|
||||||
|
* is the one that stays visible while the agent works.
|
||||||
|
*/
|
||||||
|
export default function NotesTab({ project }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="h-full min-h-0">
|
||||||
|
<NotesPanel projectId={project.id} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import AutomationTab from "./AutomationTab";
|
|||||||
import ConfigTab from "./ConfigTab";
|
import ConfigTab from "./ConfigTab";
|
||||||
import FilesTab from "./FilesTab";
|
import FilesTab from "./FilesTab";
|
||||||
import BrowserTab from "./BrowserTab";
|
import BrowserTab from "./BrowserTab";
|
||||||
|
import NotesTab from "./NotesTab";
|
||||||
import { formatUptime } from "./format";
|
import { formatUptime } from "./format";
|
||||||
import { describeLeftovers, leftoverPronoun, leftoverVerb } from "./removalReport";
|
import { describeLeftovers, leftoverPronoun, leftoverVerb } from "./removalReport";
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ const TABS = [
|
|||||||
{ id: "config", label: "Config" },
|
{ id: "config", label: "Config" },
|
||||||
{ id: "files", label: "Files" },
|
{ id: "files", label: "Files" },
|
||||||
{ id: "browser", label: "Browser" },
|
{ id: "browser", label: "Browser" },
|
||||||
|
{ id: "notes", label: "Notes" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type ProjectHomeTabId = (typeof TABS)[number]["id"];
|
export type ProjectHomeTabId = (typeof TABS)[number]["id"];
|
||||||
@@ -255,6 +257,7 @@ export default function ProjectHome({ projectId, active }: Props) {
|
|||||||
{tab === "browser" && (
|
{tab === "browser" && (
|
||||||
<BrowserTab project={project} active={active && tab === "browser"} />
|
<BrowserTab project={project} active={active && tab === "browser"} />
|
||||||
)}
|
)}
|
||||||
|
{tab === "notes" && <NotesTab project={project} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showMigration && (
|
{showMigration && (
|
||||||
|
|||||||
Reference in New Issue
Block a user