diff --git a/docs/superpowers/plans/2026-09-01-project-notes.md b/docs/superpowers/plans/2026-09-01-project-notes.md new file mode 100644 index 0000000..f41c3c9 --- /dev/null +++ b/docs/superpowers/plans/2026-09-01-project-notes.md @@ -0,0 +1,2358 @@ +# Project Notes Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a per-project Notes surface — a Project Home sub-tab plus a collapsible side dock — where each note carries a **Send to agent** button that injects it into a running Claude session's prompt. + +**Architecture:** Notes live host-side in one JSON file per project (`/triple-c/notes/{project_id}.json`), reached through free functions in `storage/notes_store.rs` and three Tauri commands. The frontend caches them in a `useNotes` hook. Sending a note converts its newlines to `\x1b\r` — the sequence Claude Code's own `/terminal-setup` installs — and pushes it through `useTerminal`'s existing ordered input queue without a trailing CR, so the user presses Enter. The dock takes space **inward**; the OS window is never resized. + +**Tech Stack:** Rust (Tauri v2.11, serde, chrono, uuid), React 18 + TypeScript, zustand, Tailwind v4, Vitest + React Testing Library. + +**Spec:** `docs/superpowers/specs/2026-09-01-project-notes-design.md` — read it first; it carries the reasoning this plan only executes, including why the dock does not widen the window (§6.1). + +## Global Constraints + +- **Design tokens only.** All colour comes from CSS custom properties in `app/src/index.css`. Filled buttons use `--accent-emphasis`, never `--accent` (it fails WCAG AA behind white text). Use `--text-disabled`, never `disabled:opacity-50`. +- **Never write `focus:outline-none`.** A global `:focus-visible` ring is defined in `index.css`. +- **Radii:** only `--radius-control` (6px) and `--radius-panel` (8px). +- **Frontend types in `lib/types.ts` must stay in sync with Rust structs in `models/`.** Field names are snake_case on both sides. +- **Every `#[tauri::command]` must be added to `generate_handler![]` in `lib.rs`.** A test at `lib.rs:822-862` scans `src/` for commands and fails the suite if one is unregistered. +- **No `tempfile` crate.** Rust tests build temp dirs with `std::env::temp_dir().join(format!("triple-c-…-{}", uuid::Uuid::new_v4().simple()))` and clean up with `fs::remove_dir_all(&dir).ok()`. +- **Frontend tests mock `lib/tauri-commands`, never `@tauri-apps/api/core`.** No test in the tree mocks `invoke` directly. +- **Timestamps** are `chrono::Utc::now().to_rfc3339()` stored as `String`. +- **Commands return `Result`;** `State<'_, AppState>` is always the last parameter when present. +- Run frontend tests with `cd app && npx vitest run `; Rust tests with `cd app/src-tauri && cargo test `. + +--- + +## File Structure + +**Create:** +- `app/src-tauri/src/models/note.rs` — the `Note` record. One responsibility: the shape and its constructor. +- `app/src-tauri/src/storage/notes_store.rs` — per-project JSON persistence. Free functions, no struct, no `AppState` entry. +- `app/src-tauri/src/commands/notes_commands.rs` — the three IPC entry points. +- `app/src/hooks/useNotes.ts` — load/save/delete plus a `SaveState` for the indicator. +- `app/src/lib/sessionName.ts` — the session display-name rule, extracted from its two copies in `MainTabs.tsx`. +- `app/src/lib/claudeInput.ts` — the `\x1b\r` newline rule, in one place. +- `app/src/components/notes/NotesPanel.tsx` — list + editor + empty state. Shared verbatim by the tab and the dock so the two surfaces cannot drift. +- `app/src/components/projects/home/NotesTab.tsx` — the Project Home sub-tab; a thin wrapper over `NotesPanel`. +- `app/src/components/notes/NoteEditor.tsx` — title + body, save on blur. +- `app/src/components/notes/SendToAgentButton.tsx` — target resolution and injection. +- `app/src/components/layout/NotesDock.tsx` — the inward-pushing dock, with a resize separator. +- `app/src/store/dockWidth.test.ts` — tests for the exported width clamp. + +**Modify:** +- `app/src-tauri/src/models/mod.rs`, `app/src-tauri/src/storage/mod.rs`, `app/src-tauri/src/commands/mod.rs` — module declarations. +- `app/src-tauri/src/lib.rs` — register three commands. +- `app/src-tauri/src/commands/project_commands.rs` — delete a project's notes on removal. +- `app/src/lib/types.ts` — the `Note` interface. +- `app/src/lib/tauri-commands.ts` — three wrappers. +- `app/src/components/layout/MainTabs.tsx` — call the extracted display-name helper from both existing sites. +- `app/src/components/terminal/TerminalView.tsx` — use the extracted newline constant. +- `app/src/components/projects/home/ProjectHome.tsx` — one `TABS` entry, one panel branch. +- `app/src/store/appState.ts` — dock open/width state, persisted to localStorage. +- `app/src/App.tsx` — render the dock beside `
`. +- `app/src/components/layout/StatusBar.tsx` — the dock toggle. + +Notes are deliberately **not** a field on `Project`: see spec §1, "Why not a field on `Project`". + +--- + +### Task 1: The `Note` model and `notes_store` + +**Files:** +- Create: `app/src-tauri/src/models/note.rs` +- Create: `app/src-tauri/src/storage/notes_store.rs` +- Modify: `app/src-tauri/src/models/mod.rs` +- Modify: `app/src-tauri/src/storage/mod.rs` +- Test: inline `#[cfg(test)]` in `notes_store.rs` + +**Interfaces:** +- Consumes: nothing. +- Produces: + - `crate::models::Note { id: String, title: String, body: String, pinned: bool, created_at: String, updated_at: String }`, with `Note::new(title: String, body: String) -> Note`. + - `crate::storage::notes_store::load(project_id: &str) -> Result, String>` + - `crate::storage::notes_store::upsert(project_id: &str, note: Note) -> Result` + - `crate::storage::notes_store::delete(project_id: &str, note_id: &str) -> Result<(), String>` + - `crate::storage::notes_store::clear(project_id: &str) -> Result<(), String>` + +- [ ] **Step 1: Write the model** + +Create `app/src-tauri/src/models/note.rs`: + +```rust +use serde::{Deserialize, Serialize}; + +/// One note. A scratchpad entry the user can also fire at a running Claude +/// session. +/// +/// Deliberately has no `kind`/`type` field. What makes a note "for the agent" +/// is that the user pressed Send, not a mode chosen when it was written — a +/// classification decision at writing time is one the user is least willing to +/// make, and it would turn one pane into two features. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Note { + pub id: String, + pub title: String, + pub body: String, + /// Pinned notes sort first, then by `updated_at` descending. + #[serde(default)] + pub pinned: bool, + pub created_at: String, + pub updated_at: String, +} + +impl Note { + pub fn new(title: String, body: String) -> Self { + let now = chrono::Utc::now().to_rfc3339(); + Self { + id: uuid::Uuid::new_v4().to_string(), + title, + body, + pinned: false, + created_at: now.clone(), + updated_at: now, + } + } +} +``` + +Add to `app/src-tauri/src/models/mod.rs`, keeping the existing alphabetical `pub mod` / `pub use` pairing: + +```rust +pub mod note; +pub use note::*; +``` + +- [ ] **Step 2: Write the failing tests** + +Create `app/src-tauri/src/storage/notes_store.rs` containing **only** the test module for now, so the tests fail to compile against absent functions: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + fn temp_dir(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!( + "triple-c-notes-{}-{}", + tag, + uuid::Uuid::new_v4().simple() + )); + std::fs::create_dir_all(&dir).expect("temp dir"); + dir + } + + #[test] + fn project_ids_cannot_escape_the_notes_directory() { + // The id arrives over IPC. It must not be able to steer the write. + assert_eq!(sanitize("../../etc/passwd"), "______etc_passwd"); + assert_eq!(sanitize("a/b"), "a_b"); + assert_eq!(sanitize("a\\b"), "a_b"); + // A real UUID must survive untouched, or every note file would move + // the first time this function changed. + assert_eq!( + sanitize("ab62cd24-51aa-4645-8f5c-17a124062050"), + "ab62cd24-51aa-4645-8f5c-17a124062050" + ); + } + + #[test] + fn a_missing_file_is_an_empty_list_not_an_error() { + let dir = temp_dir("missing"); + assert_eq!(load_in(&dir, "nobody").unwrap(), Vec::::new()); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn an_upserted_note_round_trips() { + let dir = temp_dir("roundtrip"); + let note = Note::new("Deploy steps".into(), "one\ntwo".into()); + let saved = upsert_in(&dir, "p1", note.clone()).unwrap(); + assert_eq!(saved.id, note.id); + + let loaded = load_in(&dir, "p1").unwrap(); + assert_eq!(loaded.len(), 1); + assert_eq!(loaded[0].body, "one\ntwo"); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn upserting_an_existing_id_replaces_it_and_keeps_created_at() { + let dir = temp_dir("replace"); + let mut note = Note::new("Title".into(), "first".into()); + upsert_in(&dir, "p1", note.clone()).unwrap(); + + note.body = "second".into(); + note.created_at = "1999-01-01T00:00:00Z".into(); // a client must not rewrite this + let saved = upsert_in(&dir, "p1", note.clone()).unwrap(); + + let loaded = load_in(&dir, "p1").unwrap(); + assert_eq!(loaded.len(), 1, "an upsert must not append a duplicate"); + assert_eq!(loaded[0].body, "second"); + assert_ne!( + saved.created_at, "1999-01-01T00:00:00Z", + "created_at is owned by the store, not by whatever the webview sent" + ); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn deleting_a_note_leaves_the_others_and_a_missing_one_is_success() { + let dir = temp_dir("delete"); + let keep = upsert_in(&dir, "p1", Note::new("keep".into(), "".into())).unwrap(); + let drop = upsert_in(&dir, "p1", Note::new("drop".into(), "".into())).unwrap(); + + delete_in(&dir, "p1", &drop.id).unwrap(); + let loaded = load_in(&dir, "p1").unwrap(); + assert_eq!(loaded.len(), 1); + assert_eq!(loaded[0].id, keep.id); + + // Idempotent: removing what is already gone is not an error, because + // the UI can retry a delete it never saw the result of. + delete_in(&dir, "p1", &drop.id).unwrap(); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn an_unreadable_file_is_copied_aside_and_reads_as_empty() { + // Same reasoning as migration_store: a corrupt file must not make the + // tab permanently unusable, and the bytes must not be destroyed. + let dir = temp_dir("corrupt"); + let path = notes_path_in(&dir, "p1"); + std::fs::write(&path, b"{ not json").unwrap(); + + assert_eq!(load_in(&dir, "p1").unwrap(), Vec::::new()); + assert!(path.exists(), "the unreadable file is left in place"); + + let copies: Vec<_> = std::fs::read_dir(&dir) + .unwrap() + .flatten() + .filter(|e| e.file_name().to_string_lossy().contains(".corrupt-")) + .collect(); + assert_eq!(copies.len(), 1, "the bytes must be kept exactly once"); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn a_write_leaves_no_temp_file_behind() { + let dir = temp_dir("tmp"); + upsert_in(&dir, "p1", Note::new("t".into(), "b".into())).unwrap(); + let leftovers: Vec<_> = std::fs::read_dir(&dir) + .unwrap() + .flatten() + .filter(|e| e.file_name().to_string_lossy().ends_with(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "the rename must have consumed the temp file"); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn clearing_a_project_removes_its_file_and_missing_is_success() { + let dir = temp_dir("clear"); + upsert_in(&dir, "p1", Note::new("t".into(), "b".into())).unwrap(); + assert!(notes_path_in(&dir, "p1").exists()); + + clear_in(&dir, "p1").unwrap(); + assert!(!notes_path_in(&dir, "p1").exists()); + clear_in(&dir, "p1").unwrap(); // idempotent + std::fs::remove_dir_all(&dir).ok(); + } +} +``` + +Add to `app/src-tauri/src/storage/mod.rs`, beside the other non-re-exported free-function modules: + +```rust +pub mod notes_store; +``` + +- [ ] **Step 3: Run the tests to verify they fail** + +Run: `cd app/src-tauri && cargo test notes_store` +Expected: FAIL — compile errors, `cannot find function 'sanitize'`, `'load_in'`, `'upsert_in'`, `'delete_in'`, `'clear_in'`, `'notes_path_in'`. + +- [ ] **Step 4: Write the implementation** + +Prepend to `app/src-tauri/src/storage/notes_store.rs`, above the test module: + +```rust +//! Host-side persistence for per-project notes. +//! +//! One JSON file per project under `/triple-c/notes/`, on the same +//! free-function shape as `migration_store` — no struct, nothing in +//! `AppState`, no in-memory copy. `ProjectsStore` holds a `Mutex` because it +//! caches the project list; a store that reads and writes the file per call +//! has nothing to cache and nothing to guard. +//! +//! Deliberately *not* a field on `Project`. `projects.json` is rewritten on +//! every blur by the debounced-nothing save path in `useSaveState`, so notes +//! there would mean the whole project list is rewritten per edit, and a note +//! save racing a Config save would silently drop one of them. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; + +use crate::models::Note; + +/// Serialises the read-modify-write half of an upsert or delete. +/// +/// Nothing here is cached, so there is no shared state to protect — but an +/// upsert reads the whole file, edits one entry and writes it back, and two of +/// those interleaving would lose whichever note was written first. The read +/// path does not take it. +fn write_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) +} + +/// `/triple-c/notes`, created on demand. +pub fn notes_dir() -> Result { + let dir = dirs::data_dir() + .ok_or_else(|| { + "Could not determine data directory. Set XDG_DATA_HOME on Linux.".to_string() + })? + .join("triple-c") + .join("notes"); + fs::create_dir_all(&dir).map_err(|e| format!("Failed to create notes directory: {}", e))?; + Ok(dir) +} + +/// Project ids are UUIDs, but they arrive over IPC, so refuse to let one steer +/// the write anywhere but the notes directory. +fn sanitize(project_id: &str) -> String { + project_id + .chars() + .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }) + .collect() +} + +fn notes_path_in(dir: &Path, project_id: &str) -> PathBuf { + dir.join(format!("{}.json", sanitize(project_id))) +} + +// ── Public API. Each resolves the real directory, then defers to the `_in` +// variant, which is what the tests exercise against a temp dir. `ProjectsStore` +// hardcodes `dirs::data_dir()` in its constructor and is therefore untestable +// as a unit; this store does not inherit that. ───────────────────────────── + +pub fn load(project_id: &str) -> Result, String> { + load_in(¬es_dir()?, project_id) +} + +pub fn upsert(project_id: &str, note: Note) -> Result { + upsert_in(¬es_dir()?, project_id, note) +} + +pub fn delete(project_id: &str, note_id: &str) -> Result<(), String> { + delete_in(¬es_dir()?, project_id, note_id) +} + +/// Remove a project's notes file entirely. Missing is success. +pub fn clear(project_id: &str) -> Result<(), String> { + clear_in(¬es_dir()?, project_id) +} + +// ── Implementation ───────────────────────────────────────────────────────── + +/// Read a project's notes. A missing file is an empty list. +/// +/// **An unparseable file is copied aside and left in place**, then reported as +/// empty. Erroring instead would make the Notes tab permanently unusable for +/// that project with no way out through the UI; deleting instead would destroy +/// the only copy of what the user wrote. The copy is timestamped so a second +/// corruption cannot overwrite the first — which is the one taken before +/// anything rewrote the file, and therefore the one worth having. +fn load_in(dir: &Path, project_id: &str) -> Result, String> { + let path = notes_path_in(dir, project_id); + if !path.exists() { + return Ok(Vec::new()); + } + let data = fs::read_to_string(&path).map_err(|e| format!("Failed to read notes: {}", e))?; + match serde_json::from_str::>(&data) { + Ok(notes) => Ok(notes), + Err(e) => { + keep_corrupt_copy(&path, &chrono::Utc::now()); + log::error!( + "Failed to parse notes for project {}: {} — treating as empty; the file is \ + left in place and a copy was kept beside it", + project_id, + e + ); + Ok(Vec::new()) + } + } +} + +fn keep_corrupt_copy(path: &Path, now: &chrono::DateTime) { + let backup = path.with_extension(format!("json.corrupt-{}.bak", now.format("%Y%m%d-%H%M%S"))); + if backup.exists() { + return; + } + if let Err(e) = fs::copy(path, &backup) { + log::error!("Could not keep a copy of the unreadable notes file: {}", e); + } +} + +/// Insert or replace one note, leaving the rest untouched. +/// +/// `created_at` and `id` are the store's, not the caller's: the webview sends +/// a whole `Note` back and must not be able to rewrite when a note was made. +/// `updated_at` is stamped here for the same reason. +fn upsert_in(dir: &Path, project_id: &str, mut note: Note) -> Result { + let _guard = write_lock().lock().unwrap_or_else(|e| e.into_inner()); + let mut notes = load_in(dir, project_id)?; + note.updated_at = chrono::Utc::now().to_rfc3339(); + match notes.iter_mut().find(|n| n.id == note.id) { + Some(existing) => { + note.created_at = existing.created_at.clone(); + *existing = note.clone(); + } + None => notes.push(note.clone()), + } + save_all(dir, project_id, ¬es)?; + Ok(note) +} + +/// Remove one note. Removing one that is already gone is success — the UI can +/// retry a delete whose result it never saw. +fn delete_in(dir: &Path, project_id: &str, note_id: &str) -> Result<(), String> { + let _guard = write_lock().lock().unwrap_or_else(|e| e.into_inner()); + let mut notes = load_in(dir, project_id)?; + let before = notes.len(); + notes.retain(|n| n.id != note_id); + if notes.len() == before { + return Ok(()); + } + save_all(dir, project_id, ¬es) +} + +fn clear_in(dir: &Path, project_id: &str) -> Result<(), String> { + let path = notes_path_in(dir, project_id); + match fs::remove_file(&path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(format!("Failed to remove notes: {}", e)), + } +} + +/// Atomically **and durably** write the whole list. +/// +/// Write-temp-then-rename alone is only half of it. `fs::write` returns once +/// the bytes are in the page cache; the rename is atomic with respect to other +/// readers, not to power loss. Losing power in that window leaves the rename +/// applied and the data not written — a truncated file, produced by the code +/// whose job is to prevent one. So the file is fsynced before the rename and +/// the directory after it, since the rename is directory metadata. Notes are +/// prose the user typed and nothing else holds a copy. +fn save_all(dir: &Path, project_id: &str, notes: &[Note]) -> Result<(), String> { + let path = notes_path_in(dir, project_id); + let data = serde_json::to_string_pretty(notes) + .map_err(|e| format!("Failed to serialize notes: {}", e))?; + let tmp = path.with_extension("json.tmp"); + + { + use std::io::Write; + let mut file = + fs::File::create(&tmp).map_err(|e| format!("Failed to write notes: {}", e))?; + file.write_all(data.as_bytes()) + .map_err(|e| format!("Failed to write notes: {}", e))?; + file.sync_all() + .map_err(|e| format!("Failed to flush notes to disk: {}", e))?; + } + + fs::rename(&tmp, &path).map_err(|e| format!("Failed to commit notes: {}", e))?; + sync_dir(&path); + let _ = project_id; + Ok(()) +} + +/// fsync the directory holding `path`, so the rename survives power loss. +/// +/// Best effort only where it is meaningless: Windows has no directory handle +/// to sync and returns an error for the attempt, so a failure is logged rather +/// than propagated. The file's own `sync_all` carries the data and is not best +/// effort. +fn sync_dir(path: &Path) { + let Some(dir) = path.parent() else { return }; + if let Err(e) = fs::File::open(dir).and_then(|d| d.sync_all()) { + log::debug!( + "Could not fsync the notes directory {}: {} — the file itself was flushed", + dir.display(), + e + ); + } +} +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `cd app/src-tauri && cargo test notes_store` +Expected: PASS — 7 tests. + +- [ ] **Step 6: Commit** + +```bash +git add app/src-tauri/src/models/note.rs app/src-tauri/src/models/mod.rs \ + app/src-tauri/src/storage/notes_store.rs app/src-tauri/src/storage/mod.rs +git commit -m "Add a per-project notes store" +``` + +--- + +### Task 2: Tauri commands, registration, and cleanup on project removal + +**Files:** +- Create: `app/src-tauri/src/commands/notes_commands.rs` +- Modify: `app/src-tauri/src/commands/mod.rs` +- Modify: `app/src-tauri/src/lib.rs` (the `generate_handler![]` list) +- Modify: `app/src-tauri/src/commands/project_commands.rs` (`remove_project`) + +**Interfaces:** +- Consumes: `notes_store::{load, upsert, delete, clear}` and `models::Note` from Task 1. +- Produces the IPC surface later tasks call: + - `list_notes(projectId: string) -> Note[]` + - `save_note(projectId: string, note: Note) -> Note` + - `delete_note(projectId: string, noteId: string) -> void` + +- [ ] **Step 1: Write the commands** + +Create `app/src-tauri/src/commands/notes_commands.rs`: + +```rust +use crate::models::Note; +use crate::storage::notes_store; + +/// Every project's notes, oldest concept first: pinned notes, then most +/// recently edited. +/// +/// Sorted here rather than in the webview so the dock and the tab — two views +/// of the same list — cannot drift into two different orders. +#[tauri::command] +pub async fn list_notes(project_id: String) -> Result, String> { + let mut notes = notes_store::load(&project_id)?; + notes.sort_by(|a, b| { + b.pinned + .cmp(&a.pinned) + .then_with(|| b.updated_at.cmp(&a.updated_at)) + }); + Ok(notes) +} + +/// Insert or replace one note. +/// +/// There is deliberately no whole-list setter. A bulk write is exactly the +/// clobbering this store's per-project file exists to avoid, and every caller +/// here is editing one note. +#[tauri::command] +pub async fn save_note(project_id: String, note: Note) -> Result { + notes_store::upsert(&project_id, note) +} + +#[tauri::command] +pub async fn delete_note(project_id: String, note_id: String) -> Result<(), String> { + notes_store::delete(&project_id, ¬e_id) +} +``` + +Add to `app/src-tauri/src/commands/mod.rs`, alphabetically: + +```rust +pub mod notes_commands; +``` + +Note these take no `State<'_, AppState>`: the store is free functions with nothing held in app state, so there is nothing to borrow. + +- [ ] **Step 2: Run the registration test to verify it fails** + +Run: `cd app/src-tauri && cargo test commands_are_registered` +Expected: FAIL — the `lib.rs:822-862` test reports `["list_notes", "save_note", "delete_note"]` as defined but unregistered. (If the test name differs, `cargo test --lib` surfaces it; the assertion message is "these commands exist but are not registered, so the frontend cannot call them".) + +- [ ] **Step 3: Register the commands** + +In `app/src-tauri/src/lib.rs`, inside `generate_handler![...]`, after the `// Projects` block: + +```rust + // Notes + commands::notes_commands::list_notes, + commands::notes_commands::save_note, + commands::notes_commands::delete_note, +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cd app/src-tauri && cargo test` +Expected: PASS — including the registration test. + +- [ ] **Step 5: Write the failing test for removal cleanup** + +Add to the `#[cfg(test)] mod tests` in `app/src-tauri/src/storage/notes_store.rs`: + +```rust + #[test] + fn clearing_is_what_project_removal_calls_and_it_never_fails_on_absence() { + // `remove_project` must not be able to fail because a project simply + // never had any notes — an orphaned notes file is harmless, a project + // that cannot be removed is not. + let dir = temp_dir("removal"); + assert!(clear_in(&dir, "never-had-notes").is_ok()); + std::fs::remove_dir_all(&dir).ok(); + } +``` + +- [ ] **Step 6: Run it** + +Run: `cd app/src-tauri && cargo test notes_store` +Expected: PASS (the behaviour already exists from Task 1; this pins it as a contract for the next step). + +- [ ] **Step 7: Wire cleanup into `remove_project`** + +In `app/src-tauri/src/commands/project_commands.rs`, in `remove_project`, immediately after the `purge_migration_artifacts` call: + +```rust + // A project's notes are the one piece of its state that is purely the + // user's prose, so removal takes them with it rather than leaving an + // orphan file keyed by an id nothing will ever look up again. Logged and + // not propagated: an orphaned notes file is harmless, and a project that + // cannot be removed is not. + if let Err(e) = crate::storage::notes_store::clear(&project_id) { + log::warn!("Could not remove notes for project {}: {}", project_id, e); + } +``` + +- [ ] **Step 8: Verify the whole Rust suite still passes** + +Run: `cd app/src-tauri && cargo test` +Expected: PASS. + +- [ ] **Step 9: Commit** + +```bash +git add app/src-tauri/src/commands/notes_commands.rs app/src-tauri/src/commands/mod.rs \ + app/src-tauri/src/lib.rs app/src-tauri/src/commands/project_commands.rs \ + app/src-tauri/src/storage/notes_store.rs +git commit -m "Expose notes over IPC and drop them with the project" +``` + +--- + +### Task 3: Frontend types, command wrappers, and the `useNotes` hook + +**Files:** +- Modify: `app/src/lib/types.ts` +- Modify: `app/src/lib/tauri-commands.ts` +- Create: `app/src/hooks/useNotes.ts` +- Test: `app/src/hooks/useNotes.test.ts` + +**Interfaces:** +- Consumes: the three commands from Task 2. +- Produces: + - `Note` in `lib/types.ts` — `{ id, title, body, pinned, created_at, updated_at }`, all snake_case to match Rust. + - `commands.listNotes(projectId)`, `commands.saveNote(projectId, note)`, `commands.deleteNote(projectId, noteId)`. + - `useNotes(projectId)` returning `{ notes: Note[]; loading: boolean; saveState: SaveState; createNote(): Promise; saveNote(note: Note): Promise; deleteNote(id: string): Promise; }`. + +- [ ] **Step 1: Add the type and wrappers** + +In `app/src/lib/types.ts`, beside the other record interfaces: + +```ts +/** One project note. Mirrors `models::Note` — field names are the Rust ones. */ +export interface Note { + id: string; + title: string; + body: string; + pinned: boolean; + created_at: string; + updated_at: string; +} +``` + +Add `Note` to the type import list at the top of `app/src/lib/tauri-commands.ts`, then append a wrapper block after the `// Projects` group: + +```ts +// Notes — per-project, host-side, readable with the container stopped. +export const listNotes = (projectId: string) => + invoke("list_notes", { projectId }); +/** Insert or replace one note. `created_at` and `id` are owned by the backend. */ +export const saveNote = (projectId: string, note: Note) => + invoke("save_note", { projectId, note }); +export const deleteNote = (projectId: string, noteId: string) => + invoke("delete_note", { projectId, noteId }); +``` + +- [ ] **Step 2: Write the failing test** + +Create `app/src/hooks/useNotes.test.ts`: + +```ts +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, act, waitFor } from "@testing-library/react"; +import { useNotes } from "./useNotes"; +import type { Note } from "../lib/types"; + +const listNotes = vi.fn(); +const saveNote = vi.fn(); +const deleteNote = vi.fn(); + +vi.mock("../lib/tauri-commands", () => ({ + listNotes: (p: string) => listNotes(p), + saveNote: (p: string, n: Note) => saveNote(p, n), + deleteNote: (p: string, id: string) => deleteNote(p, id), +})); + +const pushToast = vi.fn(); +vi.mock("../store/appState", () => ({ + useAppState: Object.assign( + (selector: (s: unknown) => unknown) => selector({ pushToast }), + { getState: () => ({ pushToast }) }, + ), +})); + +const note = (over: Partial = {}): Note => ({ + id: "n1", + title: "Deploy", + body: "one\ntwo", + pinned: false, + created_at: "2026-09-01T00:00:00Z", + updated_at: "2026-09-01T00:00:00Z", + ...over, +}); + +beforeEach(() => { + vi.clearAllMocks(); + listNotes.mockResolvedValue([note()]); + saveNote.mockImplementation(async (_p: string, n: Note) => n); + deleteNote.mockResolvedValue(undefined); +}); + +describe("useNotes", () => { + it("loads a project's notes on mount", async () => { + const { result } = renderHook(() => useNotes("p1")); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(listNotes).toHaveBeenCalledWith("p1"); + expect(result.current.notes).toHaveLength(1); + }); + + it("reports a failed save instead of swallowing it", async () => { + // Silent save failure is data loss: the user sees their text on screen and + // believes it is stored. Same reason `useSaveState` exists. + saveNote.mockRejectedValueOnce(new Error("disk full")); + const { result } = renderHook(() => useNotes("p1")); + await waitFor(() => expect(result.current.loading).toBe(false)); + + let ok: boolean | undefined; + await act(async () => { + ok = await result.current.saveNote(note({ body: "edited" })); + }); + + expect(ok).toBe(false); + expect(result.current.saveState.status).toBe("failed"); + expect(pushToast).toHaveBeenCalled(); + }); + + it("replaces the saved note in place rather than appending", async () => { + const { result } = renderHook(() => useNotes("p1")); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { + await result.current.saveNote(note({ body: "edited" })); + }); + + expect(result.current.notes).toHaveLength(1); + expect(result.current.notes[0].body).toBe("edited"); + }); + + it("drops a deleted note from the list", async () => { + const { result } = renderHook(() => useNotes("p1")); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { + await result.current.deleteNote("n1"); + }); + + expect(deleteNote).toHaveBeenCalledWith("p1", "n1"); + expect(result.current.notes).toHaveLength(0); + }); + + it("does not load anything for an empty project id", async () => { + // The dock renders with no project selected; it must not fire a command + // for the empty string. + renderHook(() => useNotes("")); + await waitFor(() => expect(listNotes).not.toHaveBeenCalled()); + }); +}); +``` + +- [ ] **Step 3: Run it to verify it fails** + +Run: `cd app && npx vitest run src/hooks/useNotes.test.ts` +Expected: FAIL — `Failed to resolve import "./useNotes"`. + +- [ ] **Step 4: Write the hook** + +Create `app/src/hooks/useNotes.ts`: + +```ts +import { useCallback, useEffect, useRef, useState } from "react"; +import * as commands from "../lib/tauri-commands"; +import type { Note } from "../lib/types"; +import type { SaveState } from "./useSaveState"; +import { useAppState } from "../store/appState"; + +/** A blank note, ordered to the top so the user can start typing immediately. */ +function draft(): Note { + const now = new Date().toISOString(); + return { + // The backend owns the real id; this one only has to be unique enough to + // key the list until the first save returns. + id: crypto.randomUUID(), + title: "", + body: "", + pinned: false, + created_at: now, + updated_at: now, + }; +} + +/** + * A project's notes, cached from the backend. + * + * The backend is the source of truth and this is a cache — every mutation goes + * through a command and the returned record replaces the local one, so the + * list can never drift from the file. `saveState` mirrors `useProjectSave` so + * `ui/SaveIndicator` can report the outcome: a save that fails silently is a + * user staring at text they believe is stored. + */ +export function useNotes(projectId: string) { + const [notes, setNotes] = useState([]); + const [loading, setLoading] = useState(true); + const [saveState, setSaveState] = useState({ status: "idle", error: null }); + const pushToast = useAppState((s) => s.pushToast); + const resetTimer = useRef | null>(null); + + useEffect(() => { + if (!projectId) { + setNotes([]); + setLoading(false); + return; + } + let cancelled = false; + setLoading(true); + commands + .listNotes(projectId) + .then((loaded) => { + if (!cancelled) setNotes(loaded); + }) + .catch((e) => { + if (cancelled) return; + pushToast({ + kind: "error", + message: "Could not load notes for this project", + detail: String(e), + }); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [projectId, pushToast]); + + useEffect( + () => () => { + if (resetTimer.current) clearTimeout(resetTimer.current); + }, + [], + ); + + const succeeded = useCallback(() => { + setSaveState({ status: "saved", error: null }); + if (resetTimer.current) clearTimeout(resetTimer.current); + resetTimer.current = setTimeout( + () => setSaveState({ status: "idle", error: null }), + 2500, + ); + }, []); + + const saveNote = useCallback( + async (note: Note) => { + if (resetTimer.current) clearTimeout(resetTimer.current); + setSaveState({ status: "saving", error: null }); + try { + const saved = await commands.saveNote(projectId, note); + setNotes((current) => { + const index = current.findIndex((n) => n.id === saved.id); + if (index === -1) return [saved, ...current]; + const next = [...current]; + next[index] = saved; + return next; + }); + succeeded(); + return true; + } catch (e) { + const message = String(e); + setSaveState({ status: "failed", error: message }); + pushToast({ kind: "error", message: "Could not save note", detail: message }); + return false; + } + }, + [projectId, pushToast, succeeded], + ); + + const createNote = useCallback(async () => { + const note = draft(); + // Held locally first so the editor can focus it immediately; the save + // happens on blur like every other edit. + setNotes((current) => [note, ...current]); + return note; + }, []); + + const deleteNote = useCallback( + async (noteId: string) => { + try { + await commands.deleteNote(projectId, noteId); + setNotes((current) => current.filter((n) => n.id !== noteId)); + return true; + } catch (e) { + pushToast({ kind: "error", message: "Could not delete note", detail: String(e) }); + return false; + } + }, + [projectId, pushToast], + ); + + return { notes, loading, saveState, createNote, saveNote, deleteNote }; +} +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `cd app && npx vitest run src/hooks/useNotes.test.ts` +Expected: PASS — 5 tests. + +- [ ] **Step 6: Commit** + +```bash +git add app/src/lib/types.ts app/src/lib/tauri-commands.ts \ + app/src/hooks/useNotes.ts app/src/hooks/useNotes.test.ts +git commit -m "Add the notes hook and its IPC wrappers" +``` + +--- + +### Task 4: Shared helpers — the Claude newline rule and the session display name + +Both of these already exist in the codebase as knowledge that is either about to be +duplicated or already is. This task puts each in one place before anything else depends on +it. + +**Files:** +- Create: `app/src/lib/claudeInput.ts` +- Create: `app/src/lib/claudeInput.test.ts` +- Create: `app/src/lib/sessionName.ts` +- Create: `app/src/lib/sessionName.test.ts` +- Modify: `app/src/components/terminal/TerminalView.tsx` (use the constant) +- Modify: `app/src/components/layout/MainTabs.tsx` (call the helper from both sites) + +**Interfaces:** +- Consumes: `TerminalSession` and `Project` from `lib/types.ts`. +- Produces: + - `CLAUDE_SOFT_NEWLINE: "\x1b\r"` + - `toClaudePayload(text: string): string` + - `sessionDisplayName(session: TerminalSession, project?: Project): string` + +- [ ] **Step 1: Write the failing tests** + +Create `app/src/lib/claudeInput.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { CLAUDE_SOFT_NEWLINE, toClaudePayload } from "./claudeInput"; + +describe("toClaudePayload", () => { + it("is ESC+CR, the sequence Claude Code's own /terminal-setup installs", () => { + expect(CLAUDE_SOFT_NEWLINE).toBe("\x1b\r"); + }); + + it("replaces every newline so the note arrives as one prompt", () => { + // Typed raw, each \n submits — the note would arrive as three truncated + // messages instead of one. + expect(toClaudePayload("one\ntwo\nthree")).toBe("one\x1b\rtwo\x1b\rthree"); + }); + + it("normalises CRLF, which is what a paste from Windows carries", () => { + expect(toClaudePayload("one\r\ntwo")).toBe("one\x1b\rtwo"); + }); + + it("leaves single-line text untouched", () => { + expect(toClaudePayload("just one line")).toBe("just one line"); + }); + + it("never appends a terminator", () => { + // The note lands in the prompt unsubmitted; the user presses Enter. An + // unsent prompt is recoverable, a sent one is not. + expect(toClaudePayload("text").endsWith("\r")).toBe(false); + expect(toClaudePayload("text\n")).toBe("text\x1b\r"); + }); +}); +``` + +Create `app/src/lib/sessionName.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { sessionDisplayName } from "./sessionName"; +import type { Project, TerminalSession } from "./types"; + +const session = (over: Partial = {}): TerminalSession => ({ + id: "s1", + projectId: "p1", + projectName: "api", + sessionType: "claude", + sessionName: null, + ...over, +}); + +const project = (renamed: Record = {}) => + ({ id: "p1", name: "api", renamed_session_names: renamed }) as unknown as Project; + +describe("sessionDisplayName", () => { + it("prefers a user-set custom name, prefixed with the project", () => { + expect(sessionDisplayName(session(), project({ s1: "release work" }))).toBe( + "api: release work", + ); + }); + + it("falls back to the session name when there is no custom one", () => { + expect(sessionDisplayName(session({ sessionName: "review" }), project())).toBe("review"); + }); + + it("falls back to the project name when there is no session name", () => { + expect(sessionDisplayName(session(), project())).toBe("api"); + }); + + it("marks bash sessions", () => { + expect(sessionDisplayName(session({ sessionType: "bash" }), project())).toBe("api (bash)"); + }); + + it("works with no project, which is how a closing tab renders", () => { + expect(sessionDisplayName(session())).toBe("api"); + }); + + it("does not mark bash when a custom name is set, matching the existing rule", () => { + expect( + sessionDisplayName(session({ sessionType: "bash" }), project({ s1: "logs" })), + ).toBe("api: logs"); + }); +}); +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `cd app && npx vitest run src/lib/claudeInput.test.ts src/lib/sessionName.test.ts` +Expected: FAIL — both modules unresolved. + +- [ ] **Step 3: Write the helpers** + +Create `app/src/lib/claudeInput.ts`: + +```ts +/** + * The bytes that insert a newline in Claude Code's prompt without submitting + * it: ESC then CR. + * + * These are the in-band bytes, not a guess — they are exactly what Claude + * Code's own `/terminal-setup` writes into the VS Code, Cursor, Alacritty and + * Zed keymaps, and `TerminalView`'s Shift+Enter handler has sent them since + * that feature landed. **This must not be "simplified" to `\n`:** Claude Code + * accepts `\n` too, but a shell would *run* the line, so the two session types + * would quietly diverge. + * + * That last sentence is also why anything sending this must first check the + * session is a Claude one. `bash -l`'s readline has no binding for `\e\r` and + * answers with a bell. + */ +export const CLAUDE_SOFT_NEWLINE = "\x1b\r"; + +/** + * Turn multi-line text into something that arrives in a Claude prompt as one + * message. + * + * Sent as raw keystrokes, every `\n` submits, so an N-line note would arrive + * 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. + */ +export function toClaudePayload(text: string): string { + return text.replace(/\r?\n/g, CLAUDE_SOFT_NEWLINE); +} +``` + +Create `app/src/lib/sessionName.ts`: + +```ts +import type { Project, TerminalSession } from "./types"; + +/** + * What a terminal session is called on screen. + * + * The rule used to be written twice inside `MainTabs.tsx` — once in `tabLabel` + * for the drag ghost, once inline in `renderTab` — both local and neither + * exported, so the two could disagree the moment either was edited. It is here + * because a third caller (the note send-target picker) would have made that + * three. + * + * A user-set name wins and is prefixed with the project, because a custom name + * is usually about the work rather than the project and needs the context. The + * `(bash)` marker only appears on the fallback: a session someone bothered to + * name does not need to be told apart from its neighbours. + */ +export function sessionDisplayName( + session: TerminalSession, + project?: Project, +): string { + const custom = project?.renamed_session_names?.[session.id]; + if (custom) return `${session.projectName}: ${custom}`; + return ( + (session.sessionName ?? session.projectName) + + (session.sessionType === "bash" ? " (bash)" : "") + ); +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cd app && npx vitest run src/lib/claudeInput.test.ts src/lib/sessionName.test.ts` +Expected: PASS — 11 tests. + +- [ ] **Step 5: Rewire the three existing call sites** + +In `app/src/components/terminal/TerminalView.tsx`, add the import: + +```ts +import { CLAUDE_SOFT_NEWLINE } from "../../lib/claudeInput"; +``` + +and in the Shift+Enter branch (~line 418) replace the literal, leaving every surrounding +comment exactly as it is — that comment is the reason the constant exists: + +```ts + sendInput(sessionId, CLAUDE_SOFT_NEWLINE); +``` + +In `app/src/components/layout/MainTabs.tsx`, add: + +```ts +import { sessionDisplayName } from "../../lib/sessionName"; +``` + +Replace the body of `tabLabel` (~lines 192-203) with: + +```tsx + const tabLabel = (key: string): string => { + if (isHomeTab(key)) { + return projects.find((p) => p.id === tabKeyId(key))?.name ?? ""; + } + const session = sessions.find((s) => s.id === tabKeyId(key)); + if (!session) return ""; + return sessionDisplayName( + session, + projects.find((p) => p.id === session.projectId), + ); + }; +``` + +and in `renderTab` (~lines 361-367) replace the `customName` / `baseLabel` / `displayLabel` +trio with: + +```tsx + const customName = getCustomName(session.projectId, session.id); + const displayLabel = sessionDisplayName(session, project); +``` + +`customName` is still needed — the tab's context menu uses it to decide whether to offer +"Reset name" — so only the two label locals collapse. + +- [ ] **Step 6: Verify nothing regressed** + +Run: `cd app && npx vitest run` +Expected: PASS — the whole frontend suite, including any existing `MainTabs` tests. + +- [ ] **Step 7: Commit** + +```bash +git add app/src/lib/claudeInput.ts app/src/lib/claudeInput.test.ts \ + app/src/lib/sessionName.ts app/src/lib/sessionName.test.ts \ + app/src/components/terminal/TerminalView.tsx \ + app/src/components/layout/MainTabs.tsx +git commit -m "Extract the Claude newline sequence and the session display name" +``` + +--- + +### Task 5: The Send to agent button + +**Files:** +- Create: `app/src/components/notes/SendToAgentButton.tsx` +- Create: `app/src/components/notes/SendToAgentButton.test.tsx` + +**Interfaces:** +- Consumes: `toClaudePayload` and `sessionDisplayName` (Task 4); `useTerminal().sendInput` and `.sessions`; `useAppState`'s `projects`, `setActiveTabKey`, `terminalTabKey`, `pushToast`. +- Produces: ``. + +`useTerminal()` is a plain hook callable from anywhere — its ordering queue is module scope +precisely so several components can share it — so this component needs no props beyond its +own data and no store additions. + +- [ ] **Step 1: Write the failing test** + +Create `app/src/components/notes/SendToAgentButton.test.tsx`: + +```tsx +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import SendToAgentButton from "./SendToAgentButton"; +import type { Project, TerminalSession } from "../../lib/types"; + +const sendInput = vi.fn(async () => {}); +let sessions: TerminalSession[] = []; + +vi.mock("../../hooks/useTerminal", () => ({ + useTerminal: () => ({ sessions, sendInput }), +})); + +const setActiveTabKey = vi.fn(); +const pushToast = vi.fn(); +let projects: Project[] = []; + +vi.mock("../../store/appState", () => ({ + useAppState: Object.assign( + (selector: (s: unknown) => unknown) => + selector({ projects, setActiveTabKey, pushToast }), + { getState: () => ({ projects, setActiveTabKey, pushToast }) }, + ), + terminalTabKey: (id: string) => `term:${id}`, +})); + +const session = (over: Partial = {}): TerminalSession => ({ + id: "s1", + projectId: "p1", + projectName: "api", + sessionType: "claude", + sessionName: null, + ...over, +}); + +beforeEach(() => { + vi.clearAllMocks(); + sessions = []; + projects = [{ id: "p1", name: "api", renamed_session_names: {} } as unknown as Project]; +}); + +describe("SendToAgentButton", () => { + it("is disabled when the project has no running session", () => { + render(); + expect(screen.getByRole("button", { name: /send to agent/i })).toBeDisabled(); + }); + + it("is disabled when the only session belongs to another project", () => { + sessions = [session({ projectId: "other" })]; + render(); + expect(screen.getByRole("button", { name: /send to agent/i })).toBeDisabled(); + }); + + it("is disabled when the only session is a bash tab", () => { + // `bash -l`'s readline has no binding for ESC+CR and just bells, so a + // shell is never a target. + sessions = [session({ sessionType: "bash" })]; + render(); + expect(screen.getByRole("button", { name: /send to agent/i })).toBeDisabled(); + }); + + it("sends straight to the one session, with newlines converted and no terminator", async () => { + sessions = [session()]; + render(); + + fireEvent.click(screen.getByRole("button", { name: /send to agent/i })); + + await waitFor(() => expect(sendInput).toHaveBeenCalledWith("s1", "one\x1b\rtwo")); + expect(sendInput.mock.calls[0][1].endsWith("\r")).toBe(false); + }); + + it("focuses the terminal it sent to, so the user watches it land", async () => { + sessions = [session()]; + render(); + fireEvent.click(screen.getByRole("button", { name: /send to agent/i })); + await waitFor(() => expect(setActiveTabKey).toHaveBeenCalledWith("term:s1")); + }); + + it("offers a menu of display names when several sessions are open", async () => { + sessions = [session(), session({ id: "s2", sessionName: "review" })]; + projects = [ + { id: "p1", name: "api", renamed_session_names: { s1: "release" } } as unknown as Project, + ]; + render(); + + fireEvent.click(screen.getByRole("button", { name: /send to agent/i })); + expect(sendInput).not.toHaveBeenCalled(); + + fireEvent.click(await screen.findByRole("menuitem", { name: "api: release" })); + await waitFor(() => expect(sendInput).toHaveBeenCalledWith("s1", "hi")); + }); + + it("reports a failed send rather than looking like it worked", async () => { + sessions = [session()]; + sendInput.mockRejectedValueOnce(new Error("session closed")); + render(); + fireEvent.click(screen.getByRole("button", { name: /send to agent/i })); + await waitFor(() => expect(pushToast).toHaveBeenCalled()); + }); + + it("does nothing for an empty note", () => { + sessions = [session()]; + render(); + expect(screen.getByRole("button", { name: /send to agent/i })).toBeDisabled(); + }); +}); +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cd app && npx vitest run src/components/notes/SendToAgentButton.test.tsx` +Expected: FAIL — `Failed to resolve import "./SendToAgentButton"`. + +- [ ] **Step 3: Write the component** + +Create `app/src/components/notes/SendToAgentButton.tsx`: + +```tsx +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useShallow } from "zustand/react/shallow"; +import { useTerminal } from "../../hooks/useTerminal"; +import { useAppState, terminalTabKey } from "../../store/appState"; +import { toClaudePayload } from "../../lib/claudeInput"; +import { sessionDisplayName } from "../../lib/sessionName"; +import Button from "../ui/Button"; + +interface Props { + projectId: string; + body: string; +} + +/** + * Puts a note into a running Claude session's prompt. + * + * Three behaviours by target count: none disables the button, one sends + * straight there, several ask which. It never guesses — the note goes to a + * session the user named, or to the only one there is. + * + * 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`). + */ +export default function SendToAgentButton({ projectId, body }: Props) { + const { sessions, sendInput } = useTerminal(); + const { projects, setActiveTabKey, pushToast } = useAppState( + useShallow((s) => ({ + projects: s.projects, + setActiveTabKey: s.setActiveTabKey, + pushToast: s.pushToast, + })), + ); + const [menuOpen, setMenuOpen] = useState(false); + const rootRef = useRef(null); + + const targets = useMemo( + () => + sessions.filter( + (s) => s.projectId === projectId && s.sessionType === "claude", + ), + [sessions, projectId], + ); + + const project = projects.find((p) => p.id === projectId); + const hasBody = body.trim().length > 0; + const disabled = targets.length === 0 || !hasBody; + + // Same dismissal contract as `ui/OverflowMenu` and the tab context menu. + useEffect(() => { + if (!menuOpen) return; + const onDocClick = (e: MouseEvent) => { + if (!rootRef.current?.contains(e.target as Node)) setMenuOpen(false); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setMenuOpen(false); + }; + document.addEventListener("mousedown", onDocClick); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onDocClick); + document.removeEventListener("keydown", onKey); + }; + }, [menuOpen]); + + const send = useCallback( + async (sessionId: string) => { + setMenuOpen(false); + try { + // No trailing CR: the note lands in the prompt and the user presses + // Enter. Newlines become ESC+CR so it arrives as one message rather + // than one prompt per line. + await sendInput(sessionId, toClaudePayload(body)); + // A courtesy, not part of the send: if the tab cannot be focused the + // text still went. + setActiveTabKey(terminalTabKey(sessionId)); + } catch (e) { + pushToast({ + kind: "error", + message: "Could not send the note to the agent", + detail: String(e), + }); + } + }, + [body, sendInput, setActiveTabKey, pushToast], + ); + + const onClick = useCallback(() => { + // The target is resolved at click time and pinned for the whole send, the + // hazard `useSTT` guards against by capturing its session at record start: + // the list can change while the request is in flight. + if (targets.length === 1) { + void send(targets[0].id); + return; + } + setMenuOpen((open) => !open); + }, [targets, send]); + + const title = !hasBody + ? "Nothing to send — this note is empty" + : targets.length === 0 + ? "No running Claude session for this project" + : "Put this note into the agent's prompt (you press Enter)"; + + return ( +
+ + {menuOpen && targets.length > 1 && ( +
+ {targets.map((s) => ( + + ))} +
+ )} +
+ ); +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cd app && npx vitest run src/components/notes/SendToAgentButton.test.tsx` +Expected: PASS — 8 tests. + +- [ ] **Step 5: Commit** + +```bash +git add app/src/components/notes/SendToAgentButton.tsx \ + app/src/components/notes/SendToAgentButton.test.tsx +git commit -m "Add the send-to-agent button" +``` + +--- + +### Task 6: The note editor and the Notes tab + +**Files:** +- Create: `app/src/components/notes/NoteEditor.tsx` +- Create: `app/src/components/notes/NotesPanel.tsx` +- Create: `app/src/components/notes/NotesPanel.test.tsx` +- Create: `app/src/components/projects/home/NotesTab.tsx` +- Modify: `app/src/components/projects/home/ProjectHome.tsx` + +`NotesPanel` holds the list, the editor and the empty state, and is shared verbatim by the +tab and by the dock in Task 7 — the two surfaces must not drift into two behaviours. + +**Interfaces:** +- Consumes: `useNotes` (Task 3), `SendToAgentButton` (Task 5), `ui/SaveIndicator`, `ui/Button`. +- Produces: + - `` + - `` + - `"notes"` added to `ProjectHomeTabId`. + +- [ ] **Step 1: Write the failing test** + +Create `app/src/components/notes/NotesPanel.test.tsx`: + +```tsx +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 }) => ( + + ), +})); + +const note = (over: Partial = {}): 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + fireEvent.click(screen.getByRole("button", { name: /delete note/i })); + await waitFor(() => expect(deleteNote).toHaveBeenCalledWith("n1")); + }); +}); +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cd app && npx vitest run src/components/notes/NotesPanel.test.tsx` +Expected: FAIL — `Failed to resolve import "./NotesPanel"`. + +- [ ] **Step 3: Write the editor** + +Create `app/src/components/notes/NoteEditor.tsx`: + +```tsx +import type { Note } from "../../lib/types"; +import SendToAgentButton from "./SendToAgentButton"; +import Button from "../ui/Button"; + +interface Props { + projectId: string; + note: Note; + 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, + note, + title, + body, + onTitleChange, + onBodyChange, + onCommit, + onDelete, +}: Props) { + return ( +
+
+ 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. */} + + +
+