Send a lone CR through the newline transform, and say what pinned is
`toClaudePayload` matched `/\r?\n/`, so a bare CR that is not part of a CRLF went through verbatim — and a bare CR *submits* in a Claude prompt and *runs* the line in a shell, which is the terminator the function's own contract says it never appends. A `<textarea>` cannot produce one, but `load_in` returns whatever a hand-edited or externally written notes file holds, so the guarantee has to cover that rather than only what the editor can type. `Note.pinned` is persisted and sorted on, but nothing in the app sets it: there is no pin control and no indicator. The spec stated the ordering rule as though pinning existed and §8 did not list it, so the spec is amended to say `pinned` is reserved and inert in v1, and pinning is added to the out-of-scope list. No UI is added — a user-facing affordance does not belong in a fix wave. Also renamed NotesPanel.test.tsx's "deletes the selected note and falls back to another": `useNotes` is mocked in that file and the mocked list never changes, so the fallback was never exercised. A name that claims coverage which is absent is worse than an absent test, because it makes the gap invisible. The real assertion now lives against the real hook in NotesPanel.shared.test.tsx. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HjL1E2JFNctUqCYotUwqqb
This commit is contained in:
@@ -99,7 +99,11 @@ describe("NotesPanel", () => {
|
||||
expect(screen.getByTestId("send")).toHaveTextContent("send:fresh");
|
||||
});
|
||||
|
||||
it("deletes the selected note and falls back to another", async () => {
|
||||
it("asks the hook to delete the selected note", async () => {
|
||||
// Only the call: `useNotes` is mocked here and the mocked list never
|
||||
// changes, so nothing in this file can exercise what the panel selects
|
||||
// afterwards. The fallback is covered against the real hook in
|
||||
// NotesPanel.shared.test.tsx.
|
||||
notes = [note(), note({ id: "n2", title: "Gotchas" })];
|
||||
render(<NotesPanel projectId="p1" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /delete note/i }));
|
||||
|
||||
@@ -16,6 +16,21 @@ describe("toClaudePayload", () => {
|
||||
expect(toClaudePayload("one\r\ntwo")).toBe("one\x1b\rtwo");
|
||||
});
|
||||
|
||||
it("normalises a lone CR, which would otherwise submit", () => {
|
||||
// A bare \r is a carriage return: it submits in a Claude prompt and runs
|
||||
// the line in a shell — the terminator this function promises not to
|
||||
// append. A textarea cannot make one, but a notes file that was
|
||||
// hand-edited or written by something else can, and `load_in` hands it
|
||||
// straight back.
|
||||
expect(toClaudePayload("one\rtwo")).toBe("one\x1b\rtwo");
|
||||
expect(toClaudePayload("one\rtwo\r\nthree\nfour")).toBe(
|
||||
"one\x1b\rtwo\x1b\rthree\x1b\rfour",
|
||||
);
|
||||
expect(toClaudePayload("text\r").endsWith("\r")).toBe(true);
|
||||
// …but only as the tail of the soft-newline sequence, never bare.
|
||||
expect(toClaudePayload("text\r")).toBe("text\x1b\r");
|
||||
});
|
||||
|
||||
it("leaves single-line text untouched", () => {
|
||||
expect(toClaudePayload("just one line")).toBe("just one line");
|
||||
});
|
||||
|
||||
@@ -23,7 +23,14 @@ export const CLAUDE_SOFT_NEWLINE = "\x1b\r";
|
||||
* as N truncated prompts. Deliberately appends no terminator: the text lands
|
||||
* in the prompt and the user presses Enter, which is what speech-to-text does
|
||||
* for the same reason — an unsent prompt is recoverable and a sent one is not.
|
||||
*
|
||||
* A **lone** `\r` is matched too, not only the one in a CRLF. It is a carriage
|
||||
* return: it submits in a Claude prompt and runs the line in a shell, which is
|
||||
* exactly the terminator this function promises never to append. A `<textarea>`
|
||||
* cannot produce one, but a note body is read back from a JSON file that can be
|
||||
* hand-edited or written by something else, so the guarantee has to hold for
|
||||
* whatever `load_in` returns rather than for whatever the editor can type.
|
||||
*/
|
||||
export function toClaudePayload(text: string): string {
|
||||
return text.replace(/\r?\n/g, CLAUDE_SOFT_NEWLINE);
|
||||
return text.replace(/\r\n|\r|\n/g, CLAUDE_SOFT_NEWLINE);
|
||||
}
|
||||
|
||||
@@ -85,6 +85,13 @@ struct ProjectNotes { version: u32, notes: Vec<Note> }
|
||||
|
||||
Order is pinned-first then `updated_at` descending. Manual reordering is deliberately out.
|
||||
|
||||
**`pinned` is reserved, and nothing in v1 sets it.** The field is persisted and sorted on, but
|
||||
there is no pin control and no pinned indicator anywhere in the UI, so in v1 every note sorts
|
||||
by `updated_at` descending and the pinned-first half of the rule is inert. It is carried from
|
||||
the start because it is a field in a file: adding one later means every reader has to tolerate
|
||||
its absence forever, while an unused `bool` with a serde default costs nothing. Pinning itself
|
||||
is out of scope — see §8.
|
||||
|
||||
### Why not a field on `Project`
|
||||
|
||||
`projects.json` is written on **every blur** by the debounced `useProjectSave` path
|
||||
@@ -339,6 +346,9 @@ that — but anything touching real key handling needs a manual check in Chromiu
|
||||
generic write-a-file-to-container command today (only `write_file_to_container` for image
|
||||
paste and `upload_bytes_to_container` for migration), and a second storage path with a
|
||||
sync direction is a v2 conversation.
|
||||
- Pinning. `Note.pinned` exists on both sides of the IPC boundary and the backend sorts on
|
||||
it, but no UI sets it and none indicates it — see §1. A pin control is a user-facing
|
||||
affordance and belongs in the change that adds it, not in the storage that anticipates it.
|
||||
- Tags, full-text search, manual reordering, note history.
|
||||
- Any change to `claude_instructions`. The two features stay distinct: ambient context
|
||||
versus fired-on-demand items.
|
||||
|
||||
Reference in New Issue
Block a user