Merge pull request 'Add a per-project Notes tab with a send-to-agent action' (#48) from feat/project-notes into main
Build App / compute-version (push) Successful in 4s
Secret Scan / scan (push) Successful in 5s
Build App / build-macos (push) Successful in 2m44s
Build App / build-windows (push) Successful in 4m58s
Build App / build-linux (push) Successful in 5m13s
Build App / create-tag (push) Successful in 4s
Build App / sync-to-github (push) Successful in 10s

This commit was merged in pull request #48.
This commit is contained in:
2026-09-02 20:42:07 +00:00
41 changed files with 6327 additions and 20 deletions
+1
View File
@@ -8,6 +8,7 @@ pub mod help_commands;
pub mod inspect_commands; pub mod inspect_commands;
pub mod install_helper_commands; pub mod install_helper_commands;
pub mod migration_commands; pub mod migration_commands;
pub mod notes_commands;
pub mod project_commands; pub mod project_commands;
pub mod settings_commands; pub mod settings_commands;
pub mod settings_export_commands; pub mod settings_export_commands;
@@ -0,0 +1,33 @@
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<Vec<Note>, 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<Note, String> {
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, &note_id)
}
@@ -722,6 +722,15 @@ pub async fn remove_project(
// holding an entire snapshot image that nothing will ever reference again. // holding an entire snapshot image that nothing will ever reference again.
crate::commands::migration_commands::purge_migration_artifacts(&project_id).await; crate::commands::migration_commands::purge_migration_artifacts(&project_id).await;
// 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);
}
// Stop and remove container if it exists. Everything named in `report` // Stop and remove container if it exists. Everything named in `report`
// below is what will be unreachable the moment this function drops the // below is what will be unreachable the moment this function drops the
// project record — see [`ProjectRemovalReport`] and // project record — see [`ProjectRemovalReport`] and
+4
View File
@@ -470,6 +470,10 @@ pub fn run() {
commands::project_commands::stop_project_container, commands::project_commands::stop_project_container,
commands::project_commands::rebuild_project_container, commands::project_commands::rebuild_project_container,
commands::project_commands::reconcile_project_statuses, commands::project_commands::reconcile_project_statuses,
// Notes
commands::notes_commands::list_notes,
commands::notes_commands::save_note,
commands::notes_commands::delete_note,
// Container base-image migration // Container base-image migration
commands::migration_commands::get_container_staleness, commands::migration_commands::get_container_staleness,
commands::migration_commands::migrate_project_to_base, commands::migration_commands::migrate_project_to_base,
+6 -4
View File
@@ -1,15 +1,17 @@
pub mod project;
pub mod container_config;
pub mod app_settings; pub mod app_settings;
pub mod container_config;
pub mod gateway_settings; pub mod gateway_settings;
pub mod migration; pub mod migration;
pub mod note;
pub mod project;
pub mod settings_export; pub mod settings_export;
pub mod update_info; pub mod update_info;
pub use project::*;
pub use container_config::*;
pub use app_settings::*; pub use app_settings::*;
pub use container_config::*;
pub use gateway_settings::*; pub use gateway_settings::*;
pub use migration::*; pub use migration::*;
pub use note::*;
pub use project::*;
pub use settings_export::*; pub use settings_export::*;
pub use update_info::*; pub use update_info::*;
+34
View File
@@ -0,0 +1,34 @@
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,
}
}
}
+1
View File
@@ -1,4 +1,5 @@
pub mod migration_store; pub mod migration_store;
pub mod notes_store;
pub mod pending_cleanup; pub mod pending_cleanup;
pub mod projects_store; pub mod projects_store;
pub mod secure; pub mod secure;
+593
View File
@@ -0,0 +1,593 @@
//! Host-side persistence for per-project notes.
//!
//! One JSON file per project under `<data_dir>/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 serde::{Deserialize, Serialize};
use crate::models::Note;
/// The version stamped into every notes file this build writes.
const NOTES_FORMAT_VERSION: u32 = 1;
/// What is actually on disk: a version envelope around the notes.
///
/// The list is wrapped rather than written bare because the wrapper costs
/// nothing today and cannot be added cheaply later — once files exist in the
/// field, every reader has to sniff two shapes forever. `version` is written
/// and read back but nothing branches on it yet: it is the hook a future
/// format change hangs off, and its value is only useful if it has been there
/// since the first file.
///
/// Not in `models/` and not exposed over IPC: the frontend receives
/// `Vec<Note>` from `list_notes` and never sees the envelope, so this is a
/// storage detail rather than part of the IPC contract.
#[derive(Debug, Serialize, Deserialize)]
struct ProjectNotes {
version: u32,
#[serde(default)]
notes: Vec<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<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
/// `<data_dir>/triple-c/notes`, created on demand.
pub fn notes_dir() -> Result<PathBuf, String> {
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<Vec<Note>, String> {
load_in(&notes_dir()?, project_id)
}
pub fn upsert(project_id: &str, note: Note) -> Result<Note, String> {
upsert_in(&notes_dir()?, project_id, note)
}
pub fn delete(project_id: &str, note_id: &str) -> Result<(), String> {
delete_in(&notes_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(&notes_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 — and capped,
/// because `list_notes` runs on *every* panel mount. See [`keep_corrupt_copy`].
fn load_in(dir: &Path, project_id: &str) -> Result<Vec<Note>, 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 parse(&data) {
Ok(notes) => Ok(notes),
Err(e) => {
let kept = keep_corrupt_copy(&path, &chrono::Utc::now());
log::error!(
"Failed to parse notes for project {}: {} — treating as empty; the file is \
left in place{}",
project_id,
e,
kept.describe()
);
Ok(Vec::new())
}
}
}
/// Parse a notes file: the versioned envelope, or a bare array.
///
/// The bare array is what this store wrote before [`ProjectNotes`] existed —
/// only ever on a development build, but a developer's own notes are still
/// prose nothing else holds a copy of, and the alternative is `load_in`
/// declaring a perfectly readable file corrupt. It is read, never written: the
/// first save rewrites the file with an envelope.
fn parse(data: &str) -> Result<Vec<Note>, serde_json::Error> {
match serde_json::from_str::<ProjectNotes>(data) {
Ok(file) => Ok(file.notes),
// Report the envelope's error, not the array's — the envelope is the
// shape this store writes, so its message is the one that describes
// what is actually wrong with the file.
Err(envelope_err) => serde_json::from_str::<Vec<Note>>(data).map_err(|_| envelope_err),
}
}
/// How many timestamped copies of one project's corrupt notes file are kept.
///
/// Timestamping fixes "a second corruption overwrote the first" and introduces
/// its opposite: `load_in` runs on every `list_notes`, which is every panel
/// mount — every project switch, every dock-follows-tab change, every sub-tab
/// toggle. A file that is *persistently* unparseable (the normal case, since
/// nothing repairs it) would otherwise mint a fresh full copy of the user's
/// prose every time the clock's second changed. Nothing ever reads them back
/// and nothing ever removed them.
///
/// Four is enough for the only use there is: a human looking at what the file
/// held. Same constant, same reasoning as `migration_store`.
const MAX_CORRUPT_BACKUPS: usize = 4;
/// What [`keep_corrupt_copy`] did, so the log line can tell the truth about
/// whether a file exists.
///
/// Three outcomes, and they must not be conflated. Folding "already kept
/// enough" into success and then saying "a copy was kept" names a file that
/// was never created — which is what someone reads before going to look for
/// their data.
enum Kept {
Copied(PathBuf),
/// This exact second's copy was already on disk.
AlreadyThere(PathBuf),
/// The cap is reached; the earlier copies are kept and this one is not.
EnoughAlready(usize),
Failed(String),
}
impl Kept {
fn describe(&self) -> String {
match self {
Kept::Copied(p) | Kept::AlreadyThere(p) => format!(" (a copy is at {})", p.display()),
// The earliest copies are the ones worth having, so the cap keeps
// those and drops this one. Say so, rather than implying a file
// exists.
Kept::EnoughAlready(n) => format!(
" (no copy kept — {} earlier copies of this file are already saved alongside it)",
n
),
Kept::Failed(e) => format!(" (could not keep a copy: {})", e),
}
}
}
/// Where a copy of an unreadable notes file is kept.
fn corrupt_backup_path(path: &Path, now: &chrono::DateTime<chrono::Utc>) -> PathBuf {
path.with_extension(format!("json.corrupt-{}.bak", now.format("%Y%m%d-%H%M%S")))
}
/// Whether [`MAX_CORRUPT_BACKUPS`] copies of this project's file already exist.
///
/// Asked *before* the copy rather than pruning after it, so the cap is not
/// implemented by writing a file and deleting it again on every pass — and so
/// the copies that survive are the oldest, which are the ones taken closest to
/// whatever produced the corruption.
///
/// A directory that cannot be listed answers "not full": failing open costs at
/// most one extra file, and failing closed would drop the very first copy of
/// prose nothing else has kept.
fn corrupt_backups_full(path: &Path) -> bool {
let (Some(dir), Some(stem)) = (path.parent(), path.file_stem()) else {
return false;
};
// `{stem}.json.corrupt-` — the same shape `corrupt_backup_path` builds, so
// this can never match another project's copies or an unrelated `.bak`.
let prefix = format!("{}.json.corrupt-", stem.to_string_lossy());
let Ok(entries) = fs::read_dir(dir) else {
return false;
};
entries
.flatten()
.filter(|e| {
let name = e.file_name().to_string_lossy().to_string();
name.starts_with(&prefix) && name.ends_with(".bak")
})
.count()
>= MAX_CORRUPT_BACKUPS
}
fn keep_corrupt_copy(path: &Path, now: &chrono::DateTime<chrono::Utc>) -> Kept {
let backup = corrupt_backup_path(path, now);
if backup.exists() {
return Kept::AlreadyThere(backup);
}
if corrupt_backups_full(path) {
return Kept::EnoughAlready(MAX_CORRUPT_BACKUPS);
}
match fs::copy(path, &backup) {
Ok(_) => Kept::Copied(backup),
Err(e) => Kept::Failed(e.to_string()),
}
}
/// 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<Note, String> {
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, &notes)?;
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, &notes)
}
fn clear_in(dir: &Path, project_id: &str) -> Result<(), String> {
let _guard = write_lock().lock().unwrap_or_else(|e| e.into_inner());
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 file = ProjectNotes {
version: NOTES_FORMAT_VERSION,
notes: notes.to_vec(),
};
let data = serde_json::to_string_pretty(&file)
.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);
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
);
}
}
#[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
}
fn corrupt_copies(dir: &std::path::Path) -> Vec<String> {
std::fs::read_dir(dir)
.unwrap()
.flatten()
.map(|e| e.file_name().to_string_lossy().to_string())
.filter(|n| n.contains(".corrupt-"))
.collect()
}
#[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::<Note>::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::<Note>::new());
assert!(path.exists(), "the unreadable file is left in place");
assert_eq!(
corrupt_copies(&dir).len(),
1,
"the bytes must be kept exactly once"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn what_is_written_is_a_version_envelope_not_a_bare_array() {
// The envelope costs nothing now and cannot be added cheaply once
// files exist in the field, so the very first file has to carry it.
let dir = temp_dir("envelope");
upsert_in(&dir, "p1", Note::new("t".into(), "b".into())).unwrap();
let raw = std::fs::read_to_string(notes_path_in(&dir, "p1")).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
assert_eq!(parsed["version"], NOTES_FORMAT_VERSION);
assert_eq!(parsed["notes"].as_array().unwrap().len(), 1);
assert_eq!(parsed["notes"][0]["body"], "b");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_pre_envelope_bare_array_still_reads_and_is_not_called_corrupt() {
// Only a development build ever wrote this shape, but declaring a
// perfectly readable file corrupt is the one outcome this store exists
// to avoid. It is read, never written back.
let dir = temp_dir("legacy");
let note = Note::new("Deploy".into(), "one\ntwo".into());
std::fs::write(
notes_path_in(&dir, "p1"),
serde_json::to_string(&vec![note.clone()]).unwrap(),
)
.unwrap();
let loaded = load_in(&dir, "p1").unwrap();
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].body, "one\ntwo");
let copies = corrupt_copies(&dir);
assert!(copies.is_empty(), "a readable file must not be copied aside");
// The next write upgrades it in place.
upsert_in(&dir, "p1", note).unwrap();
let raw = std::fs::read_to_string(notes_path_in(&dir, "p1")).unwrap();
assert!(raw.contains("\"version\""));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn corrupt_copies_are_capped_rather_than_one_per_second() {
// `list_notes` runs on every panel mount, so an unrepaired file would
// otherwise mint a full copy of the user's prose every time the
// clock's second changed.
let dir = temp_dir("cap");
let path = notes_path_in(&dir, "p1");
std::fs::write(&path, b"{ not json").unwrap();
let base = chrono::Utc::now();
for i in 0..MAX_CORRUPT_BACKUPS as i64 + 3 {
let at = base + chrono::Duration::seconds(i);
let kept = keep_corrupt_copy(&path, &at);
if i < MAX_CORRUPT_BACKUPS as i64 {
assert!(matches!(kept, Kept::Copied(_)), "copy {} should be kept", i);
} else {
assert!(
matches!(kept, Kept::EnoughAlready(MAX_CORRUPT_BACKUPS)),
"copy {} should be refused by the cap",
i
);
}
}
assert_eq!(corrupt_copies(&dir).len(), MAX_CORRUPT_BACKUPS);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_second_read_in_the_same_second_does_not_re_copy() {
let dir = temp_dir("samesecond");
let path = notes_path_in(&dir, "p1");
std::fs::write(&path, b"{ not json").unwrap();
let at = chrono::Utc::now();
assert!(matches!(keep_corrupt_copy(&path, &at), Kept::Copied(_)));
assert!(matches!(
keep_corrupt_copy(&path, &at),
Kept::AlreadyThere(_)
));
assert_eq!(corrupt_copies(&dir).len(), 1);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn the_log_line_never_claims_a_backup_that_was_not_written() {
// A message that invents a backup is worse than no message: it is what
// someone reads before going to look for their data.
let dir = temp_dir("honesty");
let path = notes_path_in(&dir, "p1");
std::fs::write(&path, b"{ not json").unwrap();
let copied = keep_corrupt_copy(&path, &chrono::Utc::now()).describe();
assert!(copied.contains("a copy is at"));
let refused = Kept::EnoughAlready(MAX_CORRUPT_BACKUPS).describe();
assert!(refused.contains("no copy kept"));
assert!(!refused.contains("a copy is at"));
let failed = Kept::Failed("permission denied".into()).describe();
assert!(failed.contains("could not keep a copy"));
assert!(!failed.contains("a copy is at"));
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();
}
#[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();
}
}
+2
View File
@@ -4,6 +4,7 @@ import { listen } from "@tauri-apps/api/event";
import Sidebar from "./components/layout/Sidebar"; import Sidebar from "./components/layout/Sidebar";
import TopBar from "./components/layout/TopBar"; import TopBar from "./components/layout/TopBar";
import StatusBar from "./components/layout/StatusBar"; import StatusBar from "./components/layout/StatusBar";
import NotesDock from "./components/layout/NotesDock";
import TerminalView from "./components/terminal/TerminalView"; import TerminalView from "./components/terminal/TerminalView";
import DockerInstallDialog from "./components/DockerInstallDialog"; import DockerInstallDialog from "./components/DockerInstallDialog";
import ProjectHome from "./components/projects/home/ProjectHome"; import ProjectHome from "./components/projects/home/ProjectHome";
@@ -161,6 +162,7 @@ export default function App() {
</div> </div>
)} )}
</main> </main>
<NotesDock />
</div> </div>
<StatusBar stt={stt} /> <StatusBar stt={stt} />
<ToastHost /> <ToastHost />
+6 -12
View File
@@ -10,6 +10,7 @@ import {
} from "../../store/appState"; } from "../../store/appState";
import { effectivePermissionMode } from "../projects/PermissionModeControl"; import { effectivePermissionMode } from "../projects/PermissionModeControl";
import { ProjectStatusIndicator } from "../ui/StatusIndicator"; import { ProjectStatusIndicator } from "../ui/StatusIndicator";
import { sessionDisplayName } from "../../lib/sessionName";
import type { PermissionMode } from "../../lib/types"; import type { PermissionMode } from "../../lib/types";
interface ContextMenuState { interface ContextMenuState {
@@ -195,11 +196,10 @@ export default function MainTabs() {
} }
const session = sessions.find((s) => s.id === tabKeyId(key)); const session = sessions.find((s) => s.id === tabKeyId(key));
if (!session) return ""; if (!session) return "";
const custom = getCustomName(session.projectId, session.id); return sessionDisplayName(
return custom session,
? `${session.projectName}: ${custom}` projects.find((p) => p.id === session.projectId),
: (session.sessionName ?? session.projectName) + );
(session.sessionType === "bash" ? " (bash)" : "");
}; };
const endDrag = () => { const endDrag = () => {
@@ -358,13 +358,7 @@ export default function MainTabs() {
const session = sessions.find((s) => s.id === sessionId); const session = sessions.find((s) => s.id === sessionId);
if (!session) return null; if (!session) return null;
const project = projects.find((p) => p.id === session.projectId); const project = projects.find((p) => p.id === session.projectId);
const customName = getCustomName(session.projectId, session.id); const displayLabel = sessionDisplayName(session, project);
const baseLabel =
(session.sessionName ?? session.projectName) +
(session.sessionType === "bash" ? " (bash)" : "");
const displayLabel = customName
? `${session.projectName}: ${customName}`
: baseLabel;
const isRenaming = renamingId === session.id; const isRenaming = renamingId === session.id;
const badge = project ? MODE_BADGE[effectivePermissionMode(project)] : null; const badge = project ? MODE_BADGE[effectivePermissionMode(project)] : null;
@@ -0,0 +1,113 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import NotesDock from "./NotesDock";
import type { Project, TerminalSession } from "../../lib/types";
vi.mock("../notes/NotesDockPanel", () => ({
default: ({ projectId }: { projectId: string }) => (
<div data-testid="panel">{`panel:${projectId}`}</div>
),
}));
let state: Record<string, unknown> = {};
vi.mock("../../store/appState", () => ({
useAppState: Object.assign(
(selector: (s: unknown) => unknown) => selector(state),
{ getState: () => state },
),
isHomeTab: (k: string) => k.startsWith("home:"),
isTerminalTab: (k: string) => k.startsWith("term:"),
tabKeyId: (k: string) => k.slice(k.indexOf(":") + 1),
// The mocked store module still needs to supply the width constants the
// dock imports from it for the separator's aria-value attributes.
NOTES_DOCK_MIN_WIDTH: 260,
NOTES_DOCK_MAX_WIDTH: 720,
}));
const session: TerminalSession = {
id: "s1",
projectId: "p9",
projectName: "api",
sessionType: "claude",
sessionName: null,
};
beforeEach(() => {
state = {
notesDockOpen: true,
setNotesDockOpen: vi.fn(),
toggleNotesDock: vi.fn(),
notesDockWidth: 352,
setNotesDockWidth: vi.fn(),
activeTabKey: null,
sessions: [session],
projects: [{ id: "p9", name: "api" } as unknown as Project],
};
});
describe("NotesDock", () => {
it("renders nothing when closed", () => {
state.notesDockOpen = false;
const { container } = render(<NotesDock />);
expect(container).toBeEmptyDOMElement();
});
it("follows a project home tab", () => {
state.activeTabKey = "home:p1";
render(<NotesDock />);
expect(screen.getByTestId("panel")).toHaveTextContent("panel:p1");
});
it("follows the project of the active terminal tab", () => {
// The dock exists to be visible while the agent runs, so a terminal tab
// must resolve to its project, not to nothing.
state.activeTabKey = "term:s1";
render(<NotesDock />);
expect(screen.getByTestId("panel")).toHaveTextContent("panel:p9");
});
it("explains itself when no project is active", () => {
state.activeTabKey = null;
render(<NotesDock />);
expect(screen.queryByTestId("panel")).not.toBeInTheDocument();
expect(screen.getByText(/open a project/i)).toBeInTheDocument();
});
it("shows nothing for a terminal whose session has gone", () => {
state.activeTabKey = "term:vanished";
render(<NotesDock />);
expect(screen.queryByTestId("panel")).not.toBeInTheDocument();
});
it("renders at the stored width", () => {
state.activeTabKey = "home:p1";
state.notesDockWidth = 420;
render(<NotesDock />);
expect(screen.getByLabelText("Notes")).toHaveStyle({ width: "420px" });
});
it("has a keyboard-reachable resize handle", () => {
// Drag is a mouse gesture; a separator that only responds to pointer
// events is unusable without one.
state.activeTabKey = "home:p1";
render(<NotesDock />);
const handle = screen.getByRole("separator", { name: /resize notes/i });
fireEvent.keyDown(handle, { key: "ArrowLeft" });
expect(state.setNotesDockWidth).toHaveBeenCalled();
});
it("widens on ArrowLeft and narrows on ArrowRight, by the exact step", () => {
// The dock sits on the right edge, so dragging or pressing left grows it
// and right shrinks it. Asserting only "was called" would pass even if
// the branches were swapped or the sign inverted.
state.activeTabKey = "home:p1";
render(<NotesDock />);
const handle = screen.getByRole("separator", { name: /resize notes/i });
fireEvent.keyDown(handle, { key: "ArrowLeft" });
expect(state.setNotesDockWidth).toHaveBeenLastCalledWith(368);
fireEvent.keyDown(handle, { key: "ArrowRight" });
expect(state.setNotesDockWidth).toHaveBeenLastCalledWith(336);
});
});
+128
View File
@@ -0,0 +1,128 @@
import { useShallow } from "zustand/react/shallow";
import {
useAppState,
isHomeTab,
isTerminalTab,
tabKeyId,
NOTES_DOCK_MIN_WIDTH,
NOTES_DOCK_MAX_WIDTH,
} from "../../store/appState";
import NotesDockPanel from "../notes/NotesDockPanel";
import Button from "../ui/Button";
/**
* Notes beside whatever is on screen.
*
* Project Home and Terminal are sibling top-level tabs, so notes living only
* in a sub-tab would be hidden exactly when the agent is running which is
* when a note is worth sending. The dock is the answer to that.
*
* **It takes space from inside the window and never resizes it.** Growing the
* OS window was tried and rejected on evidence: honoured under XWayland,
* silently corrupting under native Wayland, where `outer_position()` returns a
* confident `Ok(0,0)` for a window that is somewhere else. See the design doc,
* §6.1. Narrowing the terminal instead costs nothing `TerminalView`'s
* ResizeObserver already reflows xterm and resizes the container PTY.
*/
export default function NotesDock() {
const {
notesDockOpen,
setNotesDockOpen,
notesDockWidth,
setNotesDockWidth,
activeTabKey,
sessions,
} = useAppState(
useShallow((s) => ({
notesDockOpen: s.notesDockOpen,
setNotesDockOpen: s.setNotesDockOpen,
notesDockWidth: s.notesDockWidth,
setNotesDockWidth: s.setNotesDockWidth,
activeTabKey: s.activeTabKey,
sessions: s.sessions,
})),
);
// Dragging the separator. Pointer capture rather than window listeners, so
// the drag survives the pointer crossing the terminal — which swallows
// events — and ends correctly if the button is released outside the window.
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
e.preventDefault();
const handle = e.currentTarget;
handle.setPointerCapture(e.pointerId);
const startX = e.clientX;
const startWidth = notesDockWidth;
// The dock is on the right, so dragging left widens it.
const onMove = (move: PointerEvent) =>
setNotesDockWidth(startWidth + (startX - move.clientX));
const onUp = () => {
handle.releasePointerCapture(e.pointerId);
handle.removeEventListener("pointermove", onMove);
handle.removeEventListener("pointerup", onUp);
};
handle.addEventListener("pointermove", onMove);
handle.addEventListener("pointerup", onUp);
};
const onHandleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
const step = e.shiftKey ? 64 : 16;
if (e.key === "ArrowLeft") {
e.preventDefault();
setNotesDockWidth(notesDockWidth + step);
} else if (e.key === "ArrowRight") {
e.preventDefault();
setNotesDockWidth(notesDockWidth - step);
}
};
if (!notesDockOpen) return null;
// Follow whatever is in front: a home tab is its own project, a terminal tab
// is the project it belongs to.
let projectId: string | null = null;
if (activeTabKey && isHomeTab(activeTabKey)) {
projectId = tabKeyId(activeTabKey);
} else if (activeTabKey && isTerminalTab(activeTabKey)) {
projectId =
sessions.find((s) => s.id === tabKeyId(activeTabKey))?.projectId ?? null;
}
return (
<aside
aria-label="Notes"
style={{ width: `${notesDockWidth}px` }}
className="relative flex-shrink-0 flex flex-col min-h-0 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-[var(--radius-panel)] overflow-hidden"
>
{/* Separator, not decoration: it carries a role and arrow keys, because
a resize that only answers to a drag is unavailable to anyone not
using a mouse. */}
<div
role="separator"
aria-label="Resize notes panel"
aria-orientation="vertical"
aria-valuenow={notesDockWidth}
aria-valuemin={NOTES_DOCK_MIN_WIDTH}
aria-valuemax={NOTES_DOCK_MAX_WIDTH}
tabIndex={0}
onPointerDown={onPointerDown}
onKeyDown={onHandleKeyDown}
className="absolute left-0 top-0 h-full w-1.5 cursor-col-resize hover:bg-[var(--accent-muted)] transition-colors"
/>
<div className="flex items-center justify-between gap-2 px-3 h-9 flex-shrink-0 border-b border-[var(--border-color)]">
<h2 className="text-[13px] font-semibold text-[var(--text-primary)]">Notes</h2>
<Button variant="ghost" onClick={() => setNotesDockOpen(false)} aria-label="Close notes">
Close
</Button>
</div>
<div className="flex-1 min-h-0">
{projectId ? (
<NotesDockPanel projectId={projectId} />
) : (
<p className="p-4 text-[13px] text-[var(--text-secondary)]">
Open a project or a terminal to see its notes.
</p>
)}
</div>
</aside>
);
}
+11 -1
View File
@@ -10,7 +10,7 @@ interface Props {
export default function StatusBar({ stt }: Props) { export default function StatusBar({ stt }: Props) {
const { const {
projects, sessions, terminalHasSelection, activeSessionId, sttEnabled, projects, sessions, terminalHasSelection, activeSessionId, sttEnabled,
terminalAtBottom, scrollActiveToBottom, terminalAtBottom, scrollActiveToBottom, notesDockOpen, toggleNotesDock,
} = useAppState( } = useAppState(
useShallow(s => ({ useShallow(s => ({
projects: s.projects, projects: s.projects,
@@ -20,6 +20,8 @@ export default function StatusBar({ stt }: Props) {
sttEnabled: s.appSettings?.stt?.enabled, sttEnabled: s.appSettings?.stt?.enabled,
terminalAtBottom: s.terminalAtBottom, terminalAtBottom: s.terminalAtBottom,
scrollActiveToBottom: s.scrollActiveToBottom, scrollActiveToBottom: s.scrollActiveToBottom,
notesDockOpen: s.notesDockOpen,
toggleNotesDock: s.toggleNotesDock,
})) }))
); );
const running = projects.filter((p) => p.status === "running").length; const running = projects.filter((p) => p.status === "running").length;
@@ -69,6 +71,14 @@ export default function StatusBar({ stt }: Props) {
Jump to Current Jump to Current
</button> </button>
)} )}
<button
onClick={toggleNotesDock}
aria-pressed={notesDockOpen}
className="text-[var(--accent)] hover:text-[var(--accent-hover)] cursor-pointer"
title="Show or hide the notes panel beside the current tab"
>
Notes
</button>
{sttEnabled && activeSessionId && ( {sttEnabled && activeSessionId && (
<SttButton <SttButton
state={stt.state} state={stt.state}
+71
View File
@@ -0,0 +1,71 @@
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">
{/* Wraps rather than overflows. The two buttons are a group with a fixed
appetite (~190px) and the title field can shrink only so far, so in a
narrow dock the title takes the first row and the buttons the second.
Without the wrap the group is simply clipped by the dock's
`overflow-hidden`, which puts Delete off-window with no scrollbar to
reach it. */}
<div className="flex flex-wrap 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-24 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"
/>
<div className="flex items-center gap-2 flex-shrink-0">
{/* 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>
</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&rsquo;s
prompt you press Enter.
</p>
</div>
);
}
@@ -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();
});
});
+112
View File
@@ -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" });
});
});
+119
View File
@@ -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>
);
}
@@ -0,0 +1,175 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, within, fireEvent, waitFor } from "@testing-library/react";
import NotesPanel from "./NotesPanel";
import NotesDockPanel from "./NotesDockPanel";
import { useAppState } from "../../store/appState";
import type { Note } from "../../lib/types";
/**
* Two panels, one project the configuration the app actually runs in.
*
* `NotesTab` mounts a `NotesPanel` and `NotesDock` mounts a `NotesDockPanel`,
* and the dock follows the active tab's project, so opening the dock over a
* Project Home tab mounts both for the *same* project. Every other notes test
* mounts exactly one, which is precisely the configuration in which a
* per-panel cache looks correct: it is only with two that an edit made in one
* is seen or lost by the other. `useNotes` is deliberately **not** mocked
* 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[]> = {};
vi.mock("../../lib/tauri-commands", () => ({
listNotes: async (p: string) => [...(files[p] ?? [])],
saveNote: async (p: string, n: Note) => {
const list = files[p] ?? (files[p] = []);
const at = list.findIndex((x) => x.id === n.id);
if (at === -1) list.unshift(n);
else list[at] = n;
return n;
},
deleteNote: async (p: string, id: string) => {
files[p] = (files[p] ?? []).filter((x) => x.id !== id);
},
}));
vi.mock("./SendToAgentButton", () => ({
default: () => <button type="button">Send to agent</button>,
}));
const note = (over: Partial<Note> = {}): Note => ({
id: "n1",
title: "Deploy steps",
body: "one",
pinned: false,
created_at: "2026-09-01T00:00:00Z",
updated_at: "2026-09-01T00:00:00Z",
...over,
});
/** The tab and the dock, mounted together the way `App` mounts them. */
function renderBothSurfaces() {
render(
<>
<div data-testid="tab">
<NotesPanel projectId="p1" />
</div>
<div data-testid="dock">
<NotesDockPanel projectId="p1" />
</div>
</>,
);
return {
tab: () => within(screen.getByTestId("tab")),
dock: () => within(screen.getByTestId("dock")),
};
}
beforeEach(() => {
for (const key of Object.keys(files)) delete files[key];
files.p1 = [note()];
useAppState.setState({ notesByProject: {}, notesLoading: {}, toasts: [] });
});
describe("the tab and the dock both open on one project", () => {
it("shows an edit made in one surface in the other", async () => {
const { tab, dock } = renderBothSurfaces();
await waitFor(() => expect(tab().getByLabelText("Note body")).toHaveValue("one"));
const dockTitle = dock().getByLabelText("Note title");
fireEvent.change(dockTitle, { target: { value: "Deploy steps v2" } });
fireEvent.blur(dockTitle);
// The other surface's list *and* its editor, not just one of them.
await waitFor(() =>
expect(tab().getByRole("button", { name: /deploy steps v2/i })).toBeInTheDocument(),
);
expect(tab().getByLabelText("Note title")).toHaveValue("Deploy steps v2");
});
it("does not write one surface's stale copy over the other's edit", async () => {
// The reported repro: edit in the dock, then go back to the tab and edit
// there. With a cache per panel, the tab committed `{...staleNote, ...}`
// and the dock's edit was gone from disk with no error and no indicator.
const { tab, dock } = renderBothSurfaces();
await waitFor(() => expect(tab().getByLabelText("Note body")).toHaveValue("one"));
const dockTitle = dock().getByLabelText("Note title");
fireEvent.change(dockTitle, { target: { value: "Deploy steps v2" } });
fireEvent.blur(dockTitle);
await waitFor(() => expect(files.p1[0].title).toBe("Deploy steps v2"));
const tabBody = tab().getByLabelText("Note body");
fireEvent.change(tabBody, { target: { value: "two" } });
fireEvent.blur(tabBody);
await waitFor(() => expect(files.p1[0].body).toBe("two"));
expect(files.p1).toHaveLength(1);
expect(files.p1[0].title).toBe("Deploy steps v2");
});
it("reads the project once for both surfaces", async () => {
// Two panels are two `useNotes`, but the in-flight flag is per project, so
// mounting the dock over an open Notes tab does not re-read the file.
const listNotes = vi.spyOn(
await import("../../lib/tauri-commands"),
"listNotes",
);
renderBothSurfaces();
await waitFor(() =>
expect(screen.getAllByLabelText("Note body")[0]).toHaveValue("one"),
);
expect(listNotes).toHaveBeenCalledTimes(1);
listNotes.mockRestore();
});
it("keeps text the user is part-way through typing when the other surface saves", async () => {
// Showing a remote edit must never mean discarding an unsaved local one.
const { tab, dock } = renderBothSurfaces();
await waitFor(() => expect(tab().getByLabelText("Note body")).toHaveValue("one"));
const tabBody = tab().getByLabelText("Note body");
fireEvent.change(tabBody, { target: { value: "half-typed" } });
const dockBody = dock().getByLabelText("Note body");
fireEvent.change(dockBody, { target: { value: "saved in the dock" } });
fireEvent.blur(dockBody);
await waitFor(() => expect(files.p1[0].body).toBe("saved in the dock"));
expect(tabBody).toHaveValue("half-typed");
});
it("falls back to another note when the selected one is deleted", async () => {
// The claim a differently-named test in NotesPanel.test.tsx used to make
// and could not keep: `useNotes` is mocked there and its list never
// changes, so the fallback was invisible. Here the list is real.
files.p1 = [note(), note({ id: "n2", title: "Gotchas", body: "beware" })];
const { tab } = renderBothSurfaces();
await waitFor(() => expect(tab().getByLabelText("Note body")).toHaveValue("one"));
fireEvent.click(tab().getByRole("button", { name: /delete note/i }));
await waitFor(() => expect(tab().getByLabelText("Note body")).toHaveValue("beware"));
expect(tab().queryByRole("button", { name: /deploy steps/i })).not.toBeInTheDocument();
expect(files.p1).toHaveLength(1);
});
it("shows a note created in one surface in the other", async () => {
const { tab, dock } = renderBothSurfaces();
await waitFor(() => expect(tab().getByLabelText("Note body")).toHaveValue("one"));
// 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(() =>
expect(tab().getAllByRole("button", { name: /untitled note/i })).toHaveLength(1),
);
expect(files.p1).toHaveLength(2);
});
});
@@ -0,0 +1,112 @@
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("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 }));
await waitFor(() => expect(deleteNote).toHaveBeenCalledWith("n1"));
});
});
+115
View File
@@ -0,0 +1,115 @@
import { useMemo, useState } from "react";
import { useNotes } from "../../hooks/useNotes";
import { useNoteDraft } from "./useNoteDraft";
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 beside the editor when there is room, stacked above it
* when there is not. That is a **container** query, not a viewport one, because
* the two surfaces differ in width while sharing a viewport the dock opens at
* 352px and the tab is the width of the main area. A `md:` breakpoint would
* read the window and give both the same answer, which is the wrong answer for
* one of them.
*
* The threshold is arithmetic, not taste: side by side needs the 192px list,
* plus an editor wide enough for its own action row (~280px), plus the divider.
* Below ~473px the editor is narrower than its buttons, so `@lg` (512px) is the
* first stop that clears it.
*
* 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 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>
);
}
return (
<div className="@container 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 flex-col @lg:flex-row">
{/* Stacked: a capped strip of titles above the editor, so the note
being written keeps most of the height. Side by side: a full-height
column of the fixed width the editor's arithmetic assumes. */}
<ul className="flex-shrink-0 overflow-y-auto py-1 max-h-32 border-b @lg:max-h-none @lg:w-48 @lg:border-b-0 @lg:border-r border-[var(--border-color)]">
{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,191 @@
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 requestTerminalFocus = vi.fn();
const pushToast = vi.fn();
let projects: Project[] = [];
vi.mock("../../store/appState", () => ({
useAppState: Object.assign(
(selector: (s: unknown) => unknown) =>
selector({ projects, setActiveTabKey, requestTerminalFocus, pushToast }),
{
getState: () => ({
projects,
setActiveTabKey,
requestTerminalFocus,
pushToast,
}),
},
),
terminalTabKey: (id: string) => `term:${id}`,
}));
const session = (over: Partial<TerminalSession> = {}): 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", () => {
// 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" />);
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("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" })];
render(<SendToAgentButton projectId="p1" body="hello" />);
expect(
screen.getByRole("button", { name: /send to agent/i }),
).toHaveAttribute("aria-disabled", "true");
});
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
// shell is never a target.
sessions = [session({ sessionType: "bash" })];
render(<SendToAgentButton projectId="p1" body="hello" />);
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 () => {
sessions = [session()];
render(<SendToAgentButton projectId="p1" body={"one\ntwo"} />);
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(<SendToAgentButton projectId="p1" body="hi" />);
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(<SendToAgentButton projectId="p1" body="hi" />);
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(<SendToAgentButton projectId="p1" body="hi" />);
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(<SendToAgentButton projectId="p1" body=" " />);
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"));
});
});
@@ -0,0 +1,168 @@
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;
/**
* 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;
}
/**
* 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,
dropUp = false,
fullWidth = false,
}: Props) {
const { sessions, sendInput } = useTerminal();
const { projects, setActiveTabKey, requestTerminalFocus, pushToast } =
useAppState(
useShallow((s) => ({
projects: s.projects,
setActiveTabKey: s.setActiveTabKey,
requestTerminalFocus: s.requestTerminalFocus,
pushToast: s.pushToast,
})),
);
const [menuOpen, setMenuOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(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 unavailable = 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));
// 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) {
pushToast({
kind: "error",
message: "Could not send the note to the agent",
detail: String(e),
});
}
},
[body, sendInput, setActiveTabKey, requestTerminalFocus, 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 (
<div
ref={rootRef}
className={`relative ${fullWidth ? "block w-full" : "inline-block"}`}
>
<Button
variant="secondary"
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}
aria-haspopup={targets.length > 1 ? "menu" : undefined}
aria-expanded={targets.length > 1 ? menuOpen : undefined}
title={title}
>
Send to agent
</Button>
{menuOpen && targets.length > 1 && (
<div
role="menu"
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)" }}
>
{targets.map((s) => (
<button
key={s.id}
type="button"
role="menuitem"
onClick={() => void send(s.id)}
className="w-full text-left px-3 py-1.5 text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] transition-colors"
>
{sessionDisplayName(s, project)}
</button>
))}
</div>
)}
</div>
);
}
+61
View File
@@ -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,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 && (
@@ -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));
});
});
+17 -1
View File
@@ -7,6 +7,7 @@ import { openUrl } from "@tauri-apps/plugin-opener";
import "@xterm/xterm/css/xterm.css"; import "@xterm/xterm/css/xterm.css";
import { useTerminal } from "../../hooks/useTerminal"; import { useTerminal } from "../../hooks/useTerminal";
import { useAppState } from "../../store/appState"; import { useAppState } from "../../store/appState";
import { CLAUDE_SOFT_NEWLINE } from "../../lib/claudeInput";
import { import {
awsSsoRefresh, awsSsoRefresh,
openPageInContainerBrowser, openPageInContainerBrowser,
@@ -415,7 +416,7 @@ export default function TerminalView({ sessionId, active }: Props) {
!event.isComposing && !event.isComposing &&
sessionTypeRef.current === "claude" sessionTypeRef.current === "claude"
) { ) {
sendInput(sessionId, "\x1b\r"); sendInput(sessionId, CLAUDE_SOFT_NEWLINE);
// **`preventDefault()` is what stops the submit, not the `return false`.** // **`preventDefault()` is what stops the submit, not the `return false`.**
// //
// xterm's `_keyDown` returns the instant a custom handler says `false` // xterm's `_keyDown` returns the instant a custom handler says `false`
@@ -730,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
+446
View File
@@ -0,0 +1,446 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import { useNotes } from "./useNotes";
import { useAppState } from "../store/appState";
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 note = (over: Partial<Note> = {}): 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,
});
/** The toasts the hook pushed. The store is real, so this is what a user sees. */
const toasts = () => useAppState.getState().toasts;
/**
* A stand-in for the Rust store: one list per project, upsert and delete
* applied to it, `list_notes` reading it back. Several of these tests are about
* what the *list* looks like after a sequence of writes, which a per-call
* `mockResolvedValueOnce` cannot express.
*/
function fakeBackend(initial: Record<string, Note[]> = {}) {
const files: Record<string, Note[]> = { ...initial };
listNotes.mockImplementation(async (p: string) => [...(files[p] ?? [])]);
saveNote.mockImplementation(async (p: string, n: Note) => {
const list = files[p] ?? (files[p] = []);
const at = list.findIndex((x) => x.id === n.id);
if (at === -1) list.unshift(n);
else list[at] = n;
return n;
});
deleteNote.mockImplementation(async (p: string, id: string) => {
files[p] = (files[p] ?? []).filter((x) => x.id !== id);
});
return files;
}
beforeEach(() => {
vi.clearAllMocks();
// The cache is shared app state now, so it has to be reset like any other.
useAppState.setState({ notesByProject: {}, notesLoading: {}, toasts: [] });
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(toasts()).toHaveLength(1);
});
it("replaces the saved note in place rather than appending", async () => {
const { result } = renderHook(() => useNotes("p1"));
await waitFor(() => expect(result.current.loading).toBe(false));
// Mock the re-read to return the edited note
listNotes.mockResolvedValueOnce([note({ body: "edited" })]);
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());
});
it("clears the first project's notes when the projectId changes to another non-empty value", async () => {
const { result, rerender } = renderHook(
({ projectId }: { projectId: string }) => useNotes(projectId),
{ initialProps: { projectId: "p1" } },
);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.notes).toHaveLength(1);
// Change to a different project before the new fetch resolves
listNotes.mockImplementationOnce(() => new Promise(() => {})); // never resolves
rerender({ projectId: "p2" });
// The old notes should be cleared immediately
expect(result.current.notes).toHaveLength(0);
});
it("leaves no stale notes on screen when a load fails", async () => {
listNotes.mockResolvedValueOnce([note()]);
const { result, rerender } = renderHook(
({ projectId }: { projectId: string }) => useNotes(projectId),
{ initialProps: { projectId: "p1" } },
);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.notes).toHaveLength(1);
// Switch to a project whose load fails
listNotes.mockRejectedValueOnce(new Error("load failed"));
rerender({ projectId: "p2" });
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.notes).toHaveLength(0);
expect(toasts()).toHaveLength(1);
});
it("ends with the list the backend returned when saving a new note", async () => {
// Initially one note
const { result } = renderHook(() => useNotes("p1"));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.notes).toHaveLength(1);
// Saving a new note (not in the current list) re-reads and ends with the backend's list
const newNote = note({ id: "n2", title: "New" });
listNotes.mockResolvedValueOnce([newNote, note()]);
await act(async () => {
await result.current.saveNote(newNote);
});
expect(result.current.notes).toHaveLength(2);
expect(result.current.notes[0].id).toBe("n2");
});
it("re-reads the list after a successful save rather than patching in place", async () => {
const { result } = renderHook(() => useNotes("p1"));
await waitFor(() => expect(result.current.loading).toBe(false));
const callCountBefore = listNotes.mock.calls.length;
listNotes.mockResolvedValueOnce([note({ body: "edited" })]);
await act(async () => {
await result.current.saveNote(note({ body: "edited" }));
});
// listNotes should be called again after the save
expect(listNotes).toHaveBeenCalledTimes(callCountBefore + 1);
});
it("does not overwrite the new project's notes when a stale save resolves", async () => {
const { result, rerender } = renderHook(
({ projectId }: { projectId: string }) => useNotes(projectId),
{ initialProps: { projectId: "p1" } },
);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.notes[0].id).toBe("n1");
// Start a save for p1 that hangs
let resolveSave: ((note: Note) => void) | undefined;
saveNote.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveSave = resolve;
}),
);
let savePromise: Promise<boolean> | undefined;
await act(async () => {
savePromise = result.current.saveNote(note({ id: "n1" }));
});
// Switch to p2 while the save is in flight
listNotes.mockResolvedValueOnce([note({ id: "n2", title: "Project 2 Note" })]);
rerender({ projectId: "p2" });
await waitFor(() => expect(result.current.loading).toBe(false));
// Now p2's note should be displayed
expect(result.current.notes).toHaveLength(1);
expect(result.current.notes[0].id).toBe("n2");
// Resolve the stale p1 save
listNotes.mockResolvedValueOnce([note({ id: "n1", body: "edited" })]);
await act(async () => {
resolveSave?.(note({ id: "n1", body: "edited" }));
await savePromise;
});
// p2's note should still be displayed, not p1's
expect(result.current.notes).toHaveLength(1);
expect(result.current.notes[0].id).toBe("n2");
});
it("keeps the notes already on screen when a refresh fails", async () => {
// The second surface mounting for a project is a refresh behind a list the
// user is already reading. One shared cache means a failed refresh would
// otherwise blank both panels.
fakeBackend({ p1: [note()] });
const tab = renderHook(() => useNotes("p1"));
await waitFor(() => expect(tab.result.current.loading).toBe(false));
listNotes.mockRejectedValueOnce(new Error("read failed"));
const dock = renderHook(() => useNotes("p1"));
await waitFor(() => expect(toasts()).toHaveLength(1));
expect(tab.result.current.notes).toHaveLength(1);
expect(dock.result.current.notes).toHaveLength(1);
});
it("does not report the old project's save on the new project's indicator", async () => {
// The indicator is per-panel and reads "Saved ✓". Firing it after a switch
// tells the user their *current* project was written when it was not.
const { result, rerender } = renderHook(
({ projectId }: { projectId: string }) => useNotes(projectId),
{ initialProps: { projectId: "p1" } },
);
await waitFor(() => expect(result.current.loading).toBe(false));
let resolveSave: ((n: Note) => void) | undefined;
saveNote.mockImplementationOnce(
() => new Promise((resolve) => (resolveSave = resolve)),
);
let savePromise: Promise<boolean> | undefined;
await act(async () => {
savePromise = result.current.saveNote(note({ body: "edited" }));
});
rerender({ projectId: "p2" });
await waitFor(() => expect(result.current.loading).toBe(false));
await act(async () => {
resolveSave?.(note({ body: "edited" }));
await savePromise;
});
expect(result.current.saveState.status).toBe("idle");
});
it("still reports a save on the indicator of the project it was made for", 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.saveState.status).toBe("saved");
});
it("serialises a project's writes so an edit cannot be re-inserted after its delete", async () => {
// Clicking Delete while the textarea has focus fires blur first, so a save
// and a delete go out back to back. The Rust write lock stops them
// interleaving but does not order them: a delete that wins the lock is
// undone by the upsert behind it, and the note comes back on next load.
const files = fakeBackend({ p1: [note()] });
const { result } = renderHook(() => useNotes("p1"));
await waitFor(() => expect(result.current.loading).toBe(false));
const order: string[] = [];
saveNote.mockImplementationOnce(async (p: string, n: Note) => {
await new Promise((r) => setTimeout(r, 20));
order.push("save");
files[p] = [n];
return n;
});
deleteNote.mockImplementationOnce(async (p: string, id: string) => {
order.push("delete");
files[p] = (files[p] ?? []).filter((x) => x.id !== id);
});
await act(async () => {
const save = result.current.saveNote(note({ body: "typo fixed" }));
const del = result.current.deleteNote("n1");
await Promise.all([save, del]);
});
expect(order).toEqual(["save", "delete"]);
expect(files.p1).toHaveLength(0);
expect(result.current.notes).toHaveLength(0);
});
it("keeps a new note when another note is saved right after it", async () => {
// A purely local draft used to be wiped by the next re-read: two clicks of
// "New note", type in the second, blur, and the first row was gone.
const files = fakeBackend({ p1: [note()] });
const { result } = renderHook(() => useNotes("p1"));
await waitFor(() => expect(result.current.loading).toBe(false));
let first: Note | null = null;
await act(async () => {
first = await result.current.createNote();
await result.current.createNote();
});
expect(result.current.notes).toHaveLength(3);
await act(async () => {
await result.current.saveNote(note({ body: "edited" }));
});
expect(result.current.notes).toHaveLength(3);
expect(result.current.notes.some((n) => n.id === first!.id)).toBe(true);
expect(files.p1).toHaveLength(3);
});
it("shares one cache between every hook watching the same project", async () => {
// The Project Home sub-tab and the dock both mount a panel for the same
// project. Two caches meant an edit in one was invisible to the other, and
// the other's next blur wrote its stale copy back over it.
fakeBackend({ p1: [note()] });
const tab = renderHook(() => useNotes("p1"));
const dock = renderHook(() => useNotes("p1"));
await waitFor(() => expect(tab.result.current.loading).toBe(false));
await waitFor(() => expect(dock.result.current.loading).toBe(false));
// One read for both — the in-flight flag is per project, not per hook.
expect(listNotes).toHaveBeenCalledTimes(1);
await act(async () => {
await dock.result.current.saveNote(note({ body: "written in the dock" }));
});
expect(tab.result.current.notes[0].body).toBe("written in the dock");
expect(tab.result.current.notes).toBe(dock.result.current.notes);
});
it("does not blank an already-loaded list when a second panel mounts", async () => {
fakeBackend({ p1: [note()] });
const tab = renderHook(() => useNotes("p1"));
await waitFor(() => expect(tab.result.current.loading).toBe(false));
const dock = renderHook(() => useNotes("p1"));
// No "Loading notes…" flash on the second surface.
expect(dock.result.current.loading).toBe(false);
expect(dock.result.current.notes).toHaveLength(1);
});
it("does not let a slow mount read overwrite a fresher post-save refresh", async () => {
// One gesture, two requests. The tab is already loaded; the user clicks the
// dock toggle with the textarea focused, so `blur` → `saveNote` and the
// dock's mount → `list_notes` are issued in the same tick. The save's
// re-read writes the post-save list; the mount's read — issued earlier,
// still in flight — must not then land its pre-save snapshot on top of it.
const files = fakeBackend({ p1: [note({ body: "before" })] });
const tab = renderHook(() => useNotes("p1"));
await waitFor(() => expect(tab.result.current.loading).toBe(false));
expect(tab.result.current.notes[0].body).toBe("before");
// The dock's mount read: it snapshots the list as it is *now* (pre-save)
// and hangs, standing in for a plain read that is slower than
// `save_note`'s double-fsync write plus the re-read behind it.
let releaseMountRead: (() => void) | undefined;
listNotes.mockImplementationOnce(async (p: string) => {
const preSave = [...(files[p] ?? [])];
await new Promise<void>((resolve) => {
releaseMountRead = resolve;
});
return preSave;
});
const dock = renderHook(() => useNotes("p1"));
expect(releaseMountRead).toBeDefined();
// The save and its re-read complete while that read is still out.
await act(async () => {
await tab.result.current.saveNote(note({ body: "after" }));
});
expect(tab.result.current.notes[0].body).toBe("after");
// Now the stale read lands.
await act(async () => {
releaseMountRead!();
await Promise.resolve();
});
expect(tab.result.current.notes[0].body).toBe("after");
expect(dock.result.current.notes[0].body).toBe("after");
});
it("does not let a slow mount read resurrect a note deleted while it was in flight", async () => {
// The other half of the same ordering rule: a confirmed delete is newer
// than any read issued before it finished, so the read's pre-delete list
// must not be written back over the shortened one.
const files = fakeBackend({ p1: [note()] });
const tab = renderHook(() => useNotes("p1"));
await waitFor(() => expect(tab.result.current.loading).toBe(false));
let releaseMountRead: (() => void) | undefined;
listNotes.mockImplementationOnce(async (p: string) => {
const preDelete = [...(files[p] ?? [])];
await new Promise<void>((resolve) => {
releaseMountRead = resolve;
});
return preDelete;
});
const dock = renderHook(() => useNotes("p1"));
expect(releaseMountRead).toBeDefined();
await act(async () => {
await tab.result.current.deleteNote("n1");
});
expect(tab.result.current.notes).toHaveLength(0);
await act(async () => {
releaseMountRead!();
await Promise.resolve();
});
expect(tab.result.current.notes).toHaveLength(0);
expect(dock.result.current.notes).toHaveLength(0);
});
});
+350
View File
@@ -0,0 +1,350 @@
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 keeps whatever id it is handed for a note it has not seen,
// so this one is the note's real id from the first save onward.
id: crypto.randomUUID(),
title: "",
body: "",
pinned: false,
created_at: now,
updated_at: now,
};
}
/** Stable empty list, so a project with nothing cached does not re-render on identity. */
const NO_NOTES: Note[] = [];
/**
* Per-project mutation chain.
*
* A project's writes are serialised so that two of them cannot be in flight at
* once. The Rust `write_lock` stops an upsert and a delete *interleaving*; it
* does not order them, and the order is the part that matters here. Clicking
* Delete while the textarea has focus fires `blur` first, so `save_note` and
* `delete_note` are issued back to back and if the delete wins the lock, the
* upsert behind it re-inserts the note and it comes back on the next load.
* "Fix a typo, decide the note is useless, delete it" is an ordinary sequence.
*
* Module scope, not hook scope, for the reason `useTerminal`'s input queue is:
* several components call `useNotes` for the same project (the Project Home
* tab and the dock), and a per-hook chain would give each its own ordering and
* leave them racing each other which is the bug, not the fix.
*/
const mutationChains = new Map<string, Promise<unknown>>();
function enqueueMutation<T>(projectId: string, run: () => Promise<T>): Promise<T> {
const previous = mutationChains.get(projectId) ?? Promise.resolve();
// `run` on both arms: a failed mutation must not stall every later one.
const result = previous.then(run, run);
const tail = result.then(
() => {},
() => {},
);
mutationChains.set(projectId, tail);
void tail.then(() => {
// Drop the entry once idle, so closed projects do not accumulate.
if (mutationChains.get(projectId) === tail) mutationChains.delete(projectId);
});
return result;
}
/**
* Per-project write ordering for the shared notes cache.
*
* `mutationChains` orders a project's *writes* against each other. It says
* nothing about reads, and the mount load is a read that runs outside it so
* one gesture can put two requests in flight at once and let the slower one
* win. Clicking the dock toggle with the textarea focused fires `blur`
* `saveNote` and the dock's mount `list_notes` in the same tick: the save
* finishes, its re-read writes the post-save list, and then the mount's read
* issued earlier, still out lands its pre-save snapshot on top. Both panels
* show stale text until something else refreshes. It needs the plain read to
* be slower than `save_note`'s double-fsync write plus a second read, so it is
* narrow, but it was reproduced.
*
* The fix is a sequence number rather than a chain, because the two requests
* are not competing for a resource the loser's result is simply *older*, and
* the cheapest correct thing to do with it is throw it away. Every write
* claims a sequence when the request behind it is issued, and `commitNotes`
* drops one whose sequence predates what is already cached. That also closes a
* hole identity comparison cannot: on a `p1 → p2 → p1` switch a read from the
* *first* p1 era is indistinguishable from a current one by project id, and
* would land its stale list on the second era's.
*
* Note what this deliberately does **not** replace. `isCurrent()` asks whether
* this *panel* is still showing the project a save was made for, which governs
* a per-panel `SaveIndicator` and not the shared cache at all; a per-project
* counter cannot answer it. Ordering and panel identity are two questions, and
* they keep two guards.
*
* Entries are two integers per project and are never pruned: they must outlive
* every request that could still land, and the map is monotone, so a stale
* sequence can never be reissued.
*/
const notesSequences = new Map<string, { issued: number; committed: number }>();
function sequenceFor(projectId: string): { issued: number; committed: number } {
let seq = notesSequences.get(projectId);
if (!seq) notesSequences.set(projectId, (seq = { issued: 0, committed: 0 }));
return seq;
}
/**
* Claim the sequence for a write about to be issued.
*
* Called immediately before the request whose result it will commit, so that
* ordering is by *issue* time. Resolution order is exactly what cannot be
* trusted here.
*/
function issueNotesWrite(projectId: string): number {
const seq = sequenceFor(projectId);
seq.issued += 1;
return seq.issued;
}
/**
* Write a list into the cache under the sequence it was issued at, unless
* something newer has already been committed.
*
* A local patch the filter behind a confirmed delete, say is authoritative
* at the moment it applies rather than derived from an earlier read, so it
* claims its sequence here: `issued` is never below `committed`, so a freshly
* claimed one always wins, and anything still in flight behind it is correctly
* treated as stale.
*/
function commitNotes(projectId: string, seq: number, notes: Note[]): boolean {
const sequence = sequenceFor(projectId);
if (seq <= sequence.committed) return false;
sequence.committed = seq;
useAppState.getState().setProjectNotes(projectId, notes);
return true;
}
/**
* Re-read the canonical list into the shared cache.
*
* A successful save stamps a new `updated_at` and the backend sorts on it, so
* the record's position has changed and positional patching would disagree
* with what a reload would show. The backend owns the order; the webview never
* sorts. A failed re-read leaves the cache alone rather than clearing it.
*
* `true` means "the cache is current", which is why a superseded commit still
* returns it: whatever beat this read was issued later and therefore read the
* same write or a later one.
*/
async function refresh(projectId: string): Promise<boolean> {
const seq = issueNotesWrite(projectId);
try {
const reloaded = await commands.listNotes(projectId);
commitNotes(projectId, seq, reloaded);
return true;
} catch {
return false;
}
}
/**
* A project's notes, cached from the backend.
*
* The backend is the source of truth and the zustand slice is the cache
* every mutation goes through a command and the returned list replaces the
* cached one, so the list can never drift from the file. The cache lives in
* the store rather than in this hook because two surfaces show the same
* project's notes at once; see `notesByProject`.
*
* `saveState` is deliberately *not* shared: it is this panel's report of this
* panel's write, and `ui/SaveIndicator` is per-panel. A save that fails
* silently is a user staring at text they believe is stored.
*/
export function useNotes(projectId: string) {
const cached = useAppState((s) => s.notesByProject[projectId]);
const pushToast = useAppState((s) => s.pushToast);
const [saveState, setSaveState] = useState<SaveState>({ status: "idle", error: null });
const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const currentProjectId = useRef(projectId);
currentProjectId.current = projectId;
useEffect(() => {
if (!projectId) return;
// Read through `getState` rather than through subscribed values: the
// effect must fire once per project, not again every time the flag it sets
// changes. Two panels mounting for the same project therefore make one
// read, and the second renders from the cache with no loading flash.
const store = useAppState.getState();
if (store.notesLoading[projectId]) return;
store.setNotesLoading(projectId, true);
const seq = issueNotesWrite(projectId);
commands
.listNotes(projectId)
.then((loaded) => {
commitNotes(projectId, seq, loaded);
})
.catch((e) => {
// A project that has never been read caches the empty list, so a panel
// does not sit on "Loading notes…" forever. One that *has* been read
// keeps what it has: this load is a refresh behind a list already on
// screen — the second surface mounting, say — and a failed refresh
// must not blank both of them. Same rule as `refresh()`. Neither
// branch has a stale-project hazard, because the write is keyed by the
// project it belongs to.
//
// The commit goes under this read's own sequence, not a fresh one: a
// *later* read still in flight has the newer answer and must not be
// dropped in favour of this failure's empty list.
if (useAppState.getState().notesByProject[projectId] === undefined) {
commitNotes(projectId, seq, []);
}
pushToast({
kind: "error",
message: "Could not load notes for this project",
detail: String(e),
});
})
.finally(() => {
useAppState.getState().setNotesLoading(projectId, false);
});
}, [projectId, pushToast]);
useEffect(
() => () => {
if (resetTimer.current) clearTimeout(resetTimer.current);
},
[],
);
// The indicator belongs to whatever project this panel is showing *now*.
// Without this, switching project mid-save leaves the new project's
// SaveIndicator stuck on the old project's "Saving…" — the same wrong-project
// report as flashing its "Saved ✓", just in the other direction.
useEffect(() => {
if (resetTimer.current) clearTimeout(resetTimer.current);
setSaveState({ status: "idle", error: null });
}, [projectId]);
/**
* Whether this hook is still looking at the project a queued mutation was
* issued for. Only the *reporting* is gated on it the cache write is not,
* because it is keyed by project and belongs to that project either way.
* Without this, the new project's SaveIndicator flashes "Saved ✓" for the
* old project's write.
*/
const isCurrent = useCallback(
() => currentProjectId.current === projectId,
[projectId],
);
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(
(note: Note) =>
enqueueMutation(projectId, async () => {
if (isCurrent()) {
if (resetTimer.current) clearTimeout(resetTimer.current);
setSaveState({ status: "saving", error: null });
}
try {
await commands.saveNote(projectId, note);
await refresh(projectId);
if (isCurrent()) succeeded();
return true;
} catch (e) {
const message = String(e);
if (isCurrent()) setSaveState({ status: "failed", error: message });
// The toast is not project-scoped — it names the failure and stays
// readable after a switch — so it fires either way.
pushToast({ kind: "error", message: "Could not save note", detail: message });
return false;
}
}),
[projectId, pushToast, succeeded, isCurrent],
);
/**
* Create a note by persisting it, rather than holding it locally until the
* first blur.
*
* The draft used to live only in the list, which meant any *other* note
* being saved replaced the list with the backend's and the unsaved draft
* silently vanished click "New note" twice, type in the second, blur, and
* the first row is gone. Sharing one cache between two surfaces makes that
* worse rather than better: a local-only row would exist in whichever panel
* created it and nowhere else. Letting the backend own the row from the
* start removes the whole class: there is no such thing as a note in the
* list that the file does not have.
*/
const createNote = useCallback(
() =>
enqueueMutation(projectId, async () => {
const note = draft();
try {
const saved = await commands.saveNote(projectId, note);
if (!(await refresh(projectId))) {
// The note exists; only the re-read failed. Show it rather than
// leaving the user with a button that did nothing visible.
const store = useAppState.getState();
commitNotes(projectId, issueNotesWrite(projectId), [
saved,
...(store.notesByProject[projectId] ?? []),
]);
}
return saved;
} catch (e) {
pushToast({
kind: "error",
message: "Could not create note",
detail: String(e),
});
return null;
}
}),
[projectId, pushToast],
);
const deleteNote = useCallback(
(noteId: string) =>
enqueueMutation(projectId, async () => {
try {
await commands.deleteNote(projectId, noteId);
const store = useAppState.getState();
commitNotes(
projectId,
issueNotesWrite(projectId),
(store.notesByProject[projectId] ?? []).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: cached ?? NO_NOTES,
// Only "loading" before the project has ever been read — never on a
// refresh behind a list that is already on screen, and never on the second
// panel to mount for a project the first one already fetched. A failed
// load caches the empty list, so this cannot latch on.
loading: Boolean(projectId) && cached === undefined,
saveState,
createNote,
saveNote,
deleteNote,
};
}
+44
View File
@@ -0,0 +1,44 @@
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("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");
});
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");
});
});
+36
View File
@@ -0,0 +1,36 @@
/**
* 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.
*
* 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|\r|\n/g, CLAUDE_SOFT_NEWLINE);
}
+45
View File
@@ -0,0 +1,45 @@
import { describe, it, expect } from "vitest";
import { sessionDisplayName } from "./sessionName";
import type { Project, TerminalSession } from "./types";
const session = (over: Partial<TerminalSession> = {}): TerminalSession => ({
id: "s1",
projectId: "p1",
projectName: "api",
sessionType: "claude",
sessionName: null,
...over,
});
const project = (renamed: Record<string, string> = {}) =>
({ 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");
});
});
+27
View File
@@ -0,0 +1,27 @@
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)" : "")
);
}
+10 -1
View File
@@ -1,5 +1,5 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import type { Project, ProjectPath, ProjectRemovalReport, ProjectResetOutcome, ContainerInfo, AppSettings, SettingsImportPreview, SettingsImportOutcome, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo, UploadOutcome } from "./types"; import type { Project, ProjectPath, ProjectRemovalReport, ProjectResetOutcome, ContainerInfo, AppSettings, SettingsImportPreview, SettingsImportOutcome, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo, UploadOutcome, Note } from "./types";
// Docker // Docker
export const checkDocker = () => invoke<boolean>("check_docker"); export const checkDocker = () => invoke<boolean>("check_docker");
@@ -25,6 +25,15 @@ export const rebuildProjectContainer = (projectId: string) =>
export const reconcileProjectStatuses = () => export const reconcileProjectStatuses = () =>
invoke<Project[]>("reconcile_project_statuses"); invoke<Project[]>("reconcile_project_statuses");
// Notes — per-project, host-side, readable with the container stopped.
export const listNotes = (projectId: string) =>
invoke<Note[]>("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<Note>("save_note", { projectId, note });
export const deleteNote = (projectId: string, noteId: string) =>
invoke<void>("delete_note", { projectId, noteId });
// Settings // Settings
export const getSettings = () => invoke<AppSettings>("get_settings"); export const getSettings = () => invoke<AppSettings>("get_settings");
export const updateSettings = (settings: AppSettings) => export const updateSettings = (settings: AppSettings) =>
+10
View File
@@ -565,6 +565,16 @@ export interface SchedulerNotification {
created_at: string; created_at: string;
} }
/** 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;
}
// ── Auth bridge ────────────────────────────────────────────────────────────── // ── Auth bridge ──────────────────────────────────────────────────────────────
/** Which loopback family the container-side listener was found on. /** Which loopback family the container-side listener was found on.
+24
View File
@@ -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");
});
});
+137 -1
View File
@@ -1,5 +1,12 @@
import { create } from "zustand"; import { create } from "zustand";
import type { Project, TerminalSession, AppSettings, UpdateInfo, ImageUpdateInfo } from "../lib/types"; import type {
Project,
TerminalSession,
AppSettings,
UpdateInfo,
ImageUpdateInfo,
Note,
} from "../lib/types";
const SIDEBAR_COLLAPSED_KEY = "triple-c.sidebar.collapsed"; const SIDEBAR_COLLAPSED_KEY = "triple-c.sidebar.collapsed";
@@ -19,6 +26,54 @@ function persistSidebarCollapsed(value: boolean) {
} }
} }
const NOTES_DOCK_KEY = "triple-c.notes.dock";
const NOTES_DOCK_WIDTH_KEY = "triple-c.notes.dock.width";
/** Wide enough for a note, narrow enough to leave a usable terminal. */
export const NOTES_DOCK_MIN_WIDTH = 260;
export const NOTES_DOCK_MAX_WIDTH = 720;
export const NOTES_DOCK_DEFAULT_WIDTH = 352;
function loadNotesDockOpen(): boolean {
try {
return localStorage.getItem(NOTES_DOCK_KEY) === "1";
} catch {
return false;
}
}
function persistNotesDockOpen(value: boolean) {
try {
localStorage.setItem(NOTES_DOCK_KEY, value ? "1" : "0");
} catch {
// ignore — storage may be unavailable
}
}
/** Clamped on the way in as well as out: a stored value can be anything a
* previous version, a hand edit, or a different screen left behind. */
export function clampDockWidth(value: number): number {
if (!Number.isFinite(value)) return NOTES_DOCK_DEFAULT_WIDTH;
return Math.min(NOTES_DOCK_MAX_WIDTH, Math.max(NOTES_DOCK_MIN_WIDTH, Math.round(value)));
}
function loadNotesDockWidth(): number {
try {
const raw = localStorage.getItem(NOTES_DOCK_WIDTH_KEY);
return raw === null ? NOTES_DOCK_DEFAULT_WIDTH : clampDockWidth(Number(raw));
} catch {
return NOTES_DOCK_DEFAULT_WIDTH;
}
}
function persistNotesDockWidth(value: number) {
try {
localStorage.setItem(NOTES_DOCK_WIDTH_KEY, String(value));
} catch {
// ignore — storage may be unavailable
}
}
/** /**
* The main area hosts two tab kinds terminals and Project Home views in a * The main area hosts two tab kinds terminals and Project Home views in a
* single ordered strip. Tabs are addressed by a string key so one array can * single ordered strip. Tabs are addressed by a string key so one array can
@@ -89,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;
@@ -98,6 +168,29 @@ interface AppState {
/** Nudge the active tab left/right — the keyboard route to the same thing. */ /** Nudge the active tab left/right — the keyboard route to the same thing. */
moveActiveTab: (delta: number) => void; moveActiveTab: (delta: number) => void;
// Per-project notes, cached from the backend.
//
// Rust is the source of truth and this is a cache — but it has to be *one*
// cache. Notes are shown by two surfaces at once (the Project Home sub-tab
// and the dock, which resolves to the same project), and a hook-local
// `useState` in each gave them independent copies: an edit made in the dock
// was invisible to the tab, and the tab's next blur wrote its stale record
// back over it with no error and no indicator. Keyed by project id so a
// response that lands after the user has moved on updates the project it
// belongs to instead of whichever one is on screen.
//
// This is also the boundary a detached notes window would need: swap the
// transport for a `notes-changed` event and both windows feed the same slice.
notesByProject: Record<string, Note[]>;
/**
* Projects with a `list_notes` in flight, so two panels mounting for the
* same project make one read rather than two, and so a panel whose project
* has never been read can tell "loading" from "no notes".
*/
notesLoading: Record<string, boolean>;
setProjectNotes: (projectId: string, notes: Note[]) => void;
setNotesLoading: (projectId: string, loading: boolean) => void;
// Inline container progress, replacing the blocking progress modal. // Inline container progress, replacing the blocking progress modal.
containerProgress: Record<string, string>; containerProgress: Record<string, string>;
setContainerProgress: (projectId: string, message: string | null) => void; setContainerProgress: (projectId: string, message: string | null) => void;
@@ -127,6 +220,13 @@ interface AppState {
sidebarCollapsed: boolean; sidebarCollapsed: boolean;
setSidebarCollapsed: (collapsed: boolean) => void; setSidebarCollapsed: (collapsed: boolean) => void;
toggleSidebarCollapsed: () => void; toggleSidebarCollapsed: () => void;
/** The notes dock, visible over any tab including a terminal. */
notesDockOpen: boolean;
setNotesDockOpen: (open: boolean) => void;
toggleNotesDock: () => void;
/** Dock width in CSS px, clamped and persisted per machine. */
notesDockWidth: number;
setNotesDockWidth: (width: number) => void;
dockerAvailable: boolean | null; dockerAvailable: boolean | null;
setDockerAvailable: (available: boolean | null) => void; setDockerAvailable: (available: boolean | null) => void;
imageExists: boolean | null; imageExists: boolean | null;
@@ -267,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);
@@ -340,6 +443,22 @@ export const useAppState = create<AppState>((set) => ({
return { tabOrder }; return { tabOrder };
}), }),
// Notes
notesByProject: {},
notesLoading: {},
setProjectNotes: (projectId, notes) =>
set((state) => ({
notesByProject: { ...state.notesByProject, [projectId]: notes },
})),
setNotesLoading: (projectId, loading) =>
set((state) => {
if ((state.notesLoading[projectId] ?? false) === loading) return {};
const next = { ...state.notesLoading };
if (loading) next[projectId] = true;
else delete next[projectId];
return { notesLoading: next };
}),
// Container progress // Container progress
containerProgress: {}, containerProgress: {},
setContainerProgress: (projectId, message) => setContainerProgress: (projectId, message) =>
@@ -396,6 +515,23 @@ export const useAppState = create<AppState>((set) => ({
persistSidebarCollapsed(next); persistSidebarCollapsed(next);
return { sidebarCollapsed: next }; return { sidebarCollapsed: next };
}), }),
notesDockOpen: loadNotesDockOpen(),
setNotesDockOpen: (open) => {
persistNotesDockOpen(open);
set({ notesDockOpen: open });
},
toggleNotesDock: () =>
set((state) => {
const open = !state.notesDockOpen;
persistNotesDockOpen(open);
return { notesDockOpen: open };
}),
notesDockWidth: loadNotesDockWidth(),
setNotesDockWidth: (width) => {
const clamped = clampDockWidth(width);
persistNotesDockWidth(clamped);
set({ notesDockWidth: clamped });
},
dockerAvailable: null, dockerAvailable: null,
setDockerAvailable: (available) => set({ dockerAvailable: available }), setDockerAvailable: (available) => set({ dockerAvailable: available }),
imageExists: null, imageExists: null,
+62
View File
@@ -0,0 +1,62 @@
import { describe, it, expect, afterEach, vi } from "vitest";
import {
clampDockWidth,
NOTES_DOCK_MIN_WIDTH,
NOTES_DOCK_MAX_WIDTH,
NOTES_DOCK_DEFAULT_WIDTH,
} from "./appState";
describe("clampDockWidth", () => {
it("keeps a sensible width", () => {
expect(clampDockWidth(400)).toBe(400);
});
it("refuses to squeeze the dock into uselessness", () => {
expect(clampDockWidth(10)).toBe(NOTES_DOCK_MIN_WIDTH);
});
it("refuses to squeeze the terminal into uselessness", () => {
expect(clampDockWidth(5000)).toBe(NOTES_DOCK_MAX_WIDTH);
});
it("falls back for a stored value that is not a number", () => {
// localStorage holds strings and can carry anything a previous version,
// a hand edit, or a different screen left behind.
expect(clampDockWidth(Number("banana"))).toBe(NOTES_DOCK_DEFAULT_WIDTH);
});
it("rounds, because a fractional px width blurs the border", () => {
expect(clampDockWidth(400.6)).toBe(401);
});
});
// The pure function above is only half the contract: the brief calls out that
// the clamp must guard the *read* path too, because localStorage can carry
// anything a previous version, a hand edit, or a different screen left
// behind. These tests exercise the real store initialization — seeding
// localStorage, then re-importing the module fresh so its top-level
// `loadNotesDockWidth()` call runs against the seeded value — rather than a
// function pulled out just to make this testable. A future refactor that
// dropped the clamp from the load path while keeping it on the write path
// would fail these.
describe("notesDockWidth store initialization", () => {
const WIDTH_KEY = "triple-c.notes.dock.width";
afterEach(() => {
localStorage.removeItem(WIDTH_KEY);
});
it("clamps an out-of-range stored value on load", async () => {
localStorage.setItem(WIDTH_KEY, "99999");
vi.resetModules();
const { useAppState } = await import("./appState");
expect(useAppState.getState().notesDockWidth).toBe(NOTES_DOCK_MAX_WIDTH);
});
it("falls back to the default for a non-numeric stored value on load", async () => {
localStorage.setItem(WIDTH_KEY, "banana");
vi.resetModules();
const { useAppState } = await import("./appState");
expect(useAppState.getState().notesDockWidth).toBe(NOTES_DOCK_DEFAULT_WIDTH);
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,359 @@
# Project Notes — design
**Date:** 2026-09-01 · **Baseline:** v0.4 · Companion to [ROADMAP.md](../../../ROADMAP.md)
and [DESIGN-REVIEW.md](../../../DESIGN-REVIEW.md).
A per-project notes surface, with a per-note **Send to agent** action that puts the note
into a running Claude session's prompt.
---
## Why this earns a slot
DESIGN-REVIEW's coherence test says every screen answers exactly one question. Notes
answers *"what do I want to hand this agent, and what did I keep learning here?"* — and it
answers it **while the container is stopped**, which is the gap the stop/start container
model creates and the same reasoning that made Sessions/Resume the flagship.
Two things already do part of this job, and the design is shaped to avoid both:
- `Project.claude_instructions` (`models/project.rs:405`, editor at
`components/projects/ClaudeInstructionsEditor.tsx`) is per-project free text merged into
the container's `CLAUDE.md` on every start. It is **ambient** — always in context, never
addressed. Notes are **discrete and fired on demand**. If Notes drifts into a second
instructions box, it is redundant with a feature that already ships.
- A `NOTES.md` in the workspace is readable by the agent already, but unreadable by the
user when the container is stopped, and invisible to the fleet view.
What Notes uniquely adds is *addressable items with a fire-at-the-session action*.
## Decisions taken
| Decision | Choice | Rationale |
|---|---|---|
| Audience | Human scratchpad **and** agent prompts, one surface | See "no note types" below |
| Storage | Own file per project, host-side | Keeps prose out of `projects.json`; works with the container stopped |
| Send target | Project's own sessions; picker when >1 | Never guesses; mirrors STT's target-pinning guard |
| Surface | Side dock that takes space **inward**; never resizes the OS window | Phase 0 spike: growing corrupts under native Wayland, §6.1 |
| Formatting | Plain text, no markdown | It is a scratchpad; see §3 |
| Tab position | Last, after Browser | A companion to the work, not a step in it |
**No note *types*.** A note is a title plus a body. What makes one "for the agent" is that
you pressed the button, not a mode set at creation. The moment there is a "prompt note" vs
"scratch note" toggle, the pane is two features wearing one coat, and every note costs a
classification decision at the moment of writing — which is the moment the user is least
willing to make one.
---
## 1. Storage
New `app/src-tauri/src/storage/notes_store.rs`, modeled on `migration_store.rs` rather than
on `projects_store.rs`:
```
<data_dir>/triple-c/notes/{project_id}.json
```
- **`sanitize()` on the project id**, copied from `migration_store.rs:41-46`. The id arrives
over IPC; it must not be able to steer the write.
- **Atomic *and durable* write**`.tmp`, `sync_all()`, `rename()`, then fsync the
directory, per `migration_store.rs:203-261` rather than `projects_store.rs:167-179`. That
file's comment is explicit that write-temp-then-rename alone is only half of it: `fs::write`
returns once the bytes are in the page cache, so losing power in the window leaves the
rename applied and the data not written — a truncated file produced by the very code meant
to prevent one. Notes are user prose; that is the data least worth losing to a half-write.
- **Corrupt file is copied aside and left in place**, per `migration_store.rs:49-125`
timestamped, capped, and never overwriting an earlier copy, because the first copy is the
one taken before anything rewrote the file.
- **Path resolution is split for testability.** `dirs::data_dir()` is resolved in thin public
wrappers; the real work takes an explicit `&Path`. `ProjectsStore::new()` hardcodes
`dirs::data_dir()` and is therefore not constructible against a temp dir, which is why its
own tests only exercise free functions. The notes store should not inherit that limit.
```rust
struct Note {
id: String, // uuid v4
title: String,
body: String,
pinned: bool,
created_at: String, // RFC 3339
updated_at: String,
}
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
(`hooks/useSaveState.ts`, threaded through `ProjectHome.tsx:79-81` into Overview and
Config). Long user prose on that record means (a) the whole project list is rewritten every
time a note changes, and (b) a note edit and a Config edit can race, with the loser's write
clobbering the winner's. `migration_store.rs:1-12` already documents this exact reasoning
for why *it* is not in `projects.json`. Notes inherit it.
A per-project file also means a corrupt notes file loses notes for one project, not the
project list.
### Lifecycle
`remove_project` deletes the project's notes file. A failure there is logged, never fatal —
an orphaned notes file is harmless, a project that cannot be removed is not.
## 2. Commands and frontend state
Registered in `lib.rs` via `generate_handler!`. Per CLAUDE.md, application commands need
**no** entry in `capabilities/default.json`.
- `list_notes(projectId) -> Vec<Note>`
- `save_note(projectId, note) -> Note` — upsert; stamps `updated_at` backend-side
- `delete_note(projectId, noteId)`
There is deliberately **no whole-list setter**. Bulk writes are the clobbering mechanism the
storage choice above exists to avoid.
`notes_store` is a **free-function module** keyed by project id, exactly like
`migration_store` — no struct, nothing held in `AppState`, no in-memory copy of the notes.
`ProjectsStore`'s `Mutex` exists because it caches the project list in memory; a notes store
that reads and writes the file per call has nothing to cache and nothing to guard. What it
does need is that each upsert's read-modify-write is not interleaved with another's, so the
module holds one process-wide write lock (`OnceLock<Mutex<()>>`, the idiom already in
`browser_view/popout.rs`) taken for the read-modify-write, not for the read path.
Frontend: wrappers in `lib/tauri-commands.ts`, a `hooks/useNotes.ts`, and notes cached in
zustand keyed by project id. Rust is the source of truth; the cache is a cache.
The dock and the tab live in one webview, so zustand alone suffices. The store boundary is
drawn so that a future detached window (§8) only swaps the transport: Rust emits a
`notes-changed` event, both windows listen.
## 3. Editor — plain text, deliberately
A note is a title and a `<textarea>`, saved on blur, following
`ClaudeInstructionsEditor.tsx` (which saves in `onBlur` and holds no timer) and reporting
the outcome through `ui/SaveIndicator`. There is no debounce anywhere in the existing save
path — `useSaveState.ts`'s only timer is a 2500 ms reset of the "Saved ✓" label — and notes
add none. **No rich editor, no markdown library, and no markdown
rendering** — it is a scratchpad for reminders, and it stays one.
The body is stored and displayed exactly as typed. There is no view/edit mode split, so
there is no state to get wrong and no moment where the text the user is looking at is not
the text that would be sent.
This also keeps `renderMarkdown()` (`components/layout/HelpDialog.tsx:55-160`) where it is.
It was written for Help content, it entity-escapes before converting to make its
`dangerouslySetInnerHTML` sink safe, and `HelpDialog.test.tsx` asserts that escaping as a
security rule. Reusing it here would mean extracting it and giving a hand-rolled HTML
converter a second caller with different content — cost and risk, for formatting a
scratchpad. If notes later need rendering, that extraction is the change to make; it is not
this change.
One consequence worth stating: the text sent to the agent is byte-for-byte what is in the
box. Nothing is transformed on the way out except the newline substitution in §5.
## 4. The Notes tab
- One entry in the `TABS` registry (`components/projects/home/ProjectHome.tsx:24-33`), one
line in the panel switch (`:237-257`), one new `home/NotesTab.tsx` taking the sibling prop
shape `{ project: Project }`.
- Order: Notes goes **last**, after Browser — **Overview / Sessions / Automation / Config /
Files / Browser / Notes**. It is a companion to the work, not a step in it, and the
existing order runs roughly from "what is this" to "what is in it".
- Layout is master/detail: title list left, editor right.
Note that the active sub-tab is local `useState` (`ProjectHome.tsx:47`) and is not
persisted, so a closed and reopened home tab returns to Overview. Notes inherits that; it is
not worth changing here.
## 5. Send to agent
### The newline problem, and why it is already solved
A dictated STT phrase has no newlines. A note body does. Typed as raw keystrokes, every
`\n` in a body **submits a separate prompt** — the note would arrive as N truncated
messages.
The answer is in the codebase already. `components/terminal/TerminalView.tsx` (~:400-430)
handles Shift+Enter by sending `\x1b\r`, and its comment states these are "the in-band
bytes, not a guess," with an explicit warning **not** to simplify to `\n` because a shell
would *run* the line. So:
```
payload = note.body.replace(/\r?\n/g, "\x1b\r")
```
sent with **no trailing CR** — the user presses Enter. Same rationale as STT sending
without one: a note is longer than a dictated sentence, so the chance of wanting an edit
before firing is higher, and an unsent prompt is recoverable while a sent one is not.
Two consequences follow from that same comment:
1. **Only `sessionType === "claude"` sessions are offered as targets.** `bash -l`'s readline
has no binding for `\e\r` and answers with a bell. Bash tabs are not listed in the picker
at all.
2. **The sequence lives in one shared helper**, not a second `"\x1b\r"` literal. The
knowledge in that comment is hard-won and must not be duplicated away from it.
### Target resolution
`TerminalSession` (`lib/types.ts:228-234`) already carries `projectId`, `projectName`,
`sessionType` and `sessionName`, so no new plumbing is needed.
| Claude sessions for this project | Behavior |
|---|---|
| 0 | Button disabled, "no running session for this project" |
| 1 | Send |
| >1 | Menu of session display names (`Project.renamed_session_names` where set) |
The display-name rule is currently written **twice**, both copies non-exported and local to
`MainTabs.tsx``tabLabel` (:192-203) and inline in `renderTab` (:362-367). The picker would
be a third copy of a rule that already disagrees with itself the moment one copy is edited,
so it is extracted once to a shared helper and both existing sites call it. That is a
targeted improvement to code this feature depends on, not unrelated refactoring.
- **The target is pinned at click time**, per the hazard `useSTT.ts:20,30` guards against
(it pins at record-start so text does not land in whatever tab is active at stop time).
- Transport is `useTerminal`'s module-scoped ordered queue (`hooks/useTerminal.ts:32-85`,
exposed as `sendInput` at `:135-141`) → `terminal_input``exec_manager.send_input`. That
queue exists because parallel `invoke`s raced the session mutex and reordered keystrokes
(`useTerminal.ts:7-31`); a multi-line note is exactly the payload that would expose it.
- After sending, switch the active tab to that terminal so the user watches it land. This is
a courtesy, not a correctness requirement: if it cannot be delivered, the send still
succeeded.
- **Body only, not the title.** The title is an index label for the list, not content.
Explicitly *not* reused: `useProjectActions.ts:104-124`'s `openTerminalWithCommand`, which
opens a shell then types after a `setTimeout(700)`. Starting a container as a side effect of
clicking a note is too large an implicit action, and that timing hack should not spread.
## 6. Surface — a dock that takes space inward
`components/layout/NotesDock.tsx`, a flex sibling of the tab panels in `App.tsx:139-160`, so
it is visible over **any** top-level tab including Terminal. This is the point of the dock:
Project Home and Terminal are sibling top-level tabs (`layout/MainTabs.tsx`), so a
Notes-only-as-sub-tab design hides notes exactly when the agent is running.
Opening the dock **takes space from inside the window**. The terminal narrows and reflows;
the OS window is never resized or moved. `TerminalView.tsx:643-656` already has a
rAF-throttled `ResizeObserver` that calls `fitAddon.fit()` then `resize(sessionId, cols,
rows)` → `terminal_resize`, so narrowing reflows xterm *and* resizes the container PTY
correctly, with no new code.
- Width is drag-resizable, persisted to a `triple-c.notes.dock` localStorage key. Precedent:
`triple-c.sidebar.collapsed` (`store/appState.ts:4-20`) is the app's only such key today.
- **The dock follows the active tab's project** — terminal tab → that session's project,
home tab → that project, nothing active → empty state. `activeTabKey`/`tabKeyId` plus
`TerminalSession.projectId` already provide this.
- **No window geometry code at all.** No `set_size`, no `set_position`, no monitor work-area
arithmetic, no platform checks. §6.1 is why.
### 6.1 Why the dock does not widen the window — Phase 0 spike
The original design had the dock "expand outward" by widening the OS window, so the terminal
kept its size. A throwaway Tauri app (`geo-spike`) was built and run on the target desktop —
KDE Plasma, Wayland session, 2026-09-01 — because the app contains no window-geometry code
today and the behavior could not be predicted. It was run twice, once per GDK backend,
which turned out to matter more than the platform.
**Under XWayland** (what every Tauri AppImage gets, because `linuxdeploy-plugin-gtk` exports
`GDK_BACKEND=x11` in `AppRun`, citing
[tauri-apps/tauri#8541](https://github.com/tauri-apps/tauri/issues/8541)) everything worked:
| Test | Result |
|---|---|
| Grow while floating | asked +420, got +420 — exact |
| Shrink back | asked -420, got -420 — exact |
| `outer_position()` | readable, correct |
| `work_area` | 3840x2099 — correctly excludes the 61px Plasma panel |
| Grow while maximized / fullscreen | ignored, as designed |
**Under native Wayland** (what the `.deb` and `.rpm` builds get, since they carry no such
hook) the same binary failed — and failed *silently*, which is the part that decided this:
| Test | Result |
|---|---|
| Grow while floating | asked +420, got **+600**; height moved **+276 unrequested** |
| Shrink back | asked -420, got **-240**; height **+276** again |
| After unmaximize | window reports **5400x2900 on a 4800x2700 monitor** |
| `outer_position()` | returned `Ok(0,0)` — for a window that was not at 0,0 |
| Grow while maximized / fullscreen | ignored, as designed |
| `set_position` | ignored, as expected |
Two independent failures, either one sufficient:
1. **Resize compounds.** Under Wayland GTK owns the frame and shadows; both `outer` and
`inner` report a 0x0 decoration, so every read-back is inflated by a fixed offset and
every write built on a read-back compounds it. There is no size that can be read and
safely written back. Three calls in, the window is larger than the display.
2. **Position is a confident lie, not an honest failure.** `outer_position()` returned
`Ok(0,0)` rather than an error. A design that treats "cannot determine position" as
"do not grow" never triggers, because the value looks perfectly valid. The room check
duly reported `slack: 2820px, VERDICT: Grow` from a false origin.
The second point is what rules out a runtime fallback. A clean failure could have been
handled; a plausible wrong answer cannot be detected from the value itself.
Growing therefore works on one packaging channel and corrupts on another — the split is by
**packaging, not platform**, which is worse than a platform split because two users on
identical hardware and OS would see different behavior. A dock that takes space inward
behaves identically on every backend, OS and package, needs no detection, and reuses a
resize path that is already exercised by every terminal in the app.
**Kept as evidence, not as guidance:** `set_position` was honoured under XWayland. The design
does not move the window and must not start.
## 7. Testing
Vitest + jsdom + React Testing Library for the frontend, `#[cfg(test)]` for Rust, per
CLAUDE.md's Testing section.
**Rust (`notes_store.rs`)**
- `sanitize()` rejects traversal and separator characters in a project id
- atomic write leaves no `.tmp` behind; a crash mid-write leaves the previous file intact
- a corrupt file is moved to `.bak` and the store opens empty rather than erroring
- removing a project deletes its notes file; a delete failure does not fail removal
**Frontend**
- send-target resolution at 0 / 1 / N claude sessions, and that bash sessions are excluded
- the newline transform: a multi-line body becomes `\x1b\r`-joined, with no trailing CR
- the dock's project resolution: terminal tab, home tab, and nothing active
- save-on-blur persists, and dock width round-trips through localStorage
Note the limit `TerminalView.tsx`'s own comment records: jsdom never synthesizes the
follow-up keypress, so keyboard-path bugs of that family are invisible to unit tests. The
send path is a direct `sendInput` call rather than a synthetic keystroke, which sidesteps
that — but anything touching real key handling needs a manual check in Chromium.
## 8. Out of scope for v1
- A detached second window for a second monitor. The store boundary in §2 is drawn so it is
an additive follow-up: emit `notes-changed` from the store's write path, and add a second
narrowly scoped capability granting the notes window `core:event:allow-listen` /
`allow-unlisten``capabilities/default.json` scopes those to `"windows": ["main"]` today,
and cross-window sync needs them. Application commands need no ACL entry (CLAUDE.md, Key
Conventions), so only the events require it. `lib.rs:379-387`'s main-window-only close
handler would need review at that point.
- Syncing notes into the workspace as `.md` for the agent to read unprompted. There is no
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.
## 9. Open questions
None. The Phase 0 spike settled the surface (§6.1); every other decision is recorded in the
table above.