Compare commits

...
6 Commits
Author SHA1 Message Date
shadowdao b6ba6deb09 Fix critical data corruption and stale-data bugs in useNotes hook
- Finding 1 (saveNote): After a successful save, re-read the canonical list from the backend instead of patching in place. A successful save stamps a new updated_at, and the backend sorts by updated_at descending, so the record's position has changed and positional patching would disagree with what a reload would show. If the re-read fails, keep the save reported as successful and leave the existing list alone.

- Finding 2 (stale notes): Clear notes on projectId change (not only when empty) and on load failure. Previously, switching from project A to project B would leave A's notes on screen until B's fetch resolved, and if a user edited one, A's note would be written into B's notes file—cross-project data corruption. If a load fails, A's notes stay visible under B indefinitely.

- Added four new tests covering these scenarios: projectId change clears old notes, failed load leaves no stale notes, saving a new note ends with the backend's list, and saves re-read the list rather than patching.
2026-09-01 12:45:35 -07:00
shadowdao cd3160b1cd Add the notes hook and its IPC wrappers 2026-09-01 12:38:33 -07:00
shadowdao 60abff1717 Expose notes over IPC and drop them with the project 2026-09-01 12:34:35 -07:00
shadowdao cc767bd544 Add a per-project notes store 2026-09-01 12:28:09 -07:00
shadowdaoandClaude Opus 5 221e7566c3 Plan the project Notes implementation
Seven tasks, each ending in a testable deliverable: the store, the IPC
surface, the hook, the two shared helpers, the send button, the tab, and
the dock.

Two extractions are folded in rather than left for later, both because
this feature would otherwise duplicate knowledge that is already written
down. `\x1b\r` becomes `lib/claudeInput.ts` so the hard-won comment in
`TerminalView` stays the single source of truth for a sequence that must
never be "simplified" to `\n`. The session display-name rule becomes
`lib/sessionName.ts`, which is a fix rather than a precaution: the rule is
currently written twice inside `MainTabs.tsx`, both copies local and
non-exported, and the send-target picker would have made three.

The spec is also corrected in three places against what the code actually
does. `migration_store` is a free-function module with no struct, so the
notes store is too, and the "read-modify-write under the store's Mutex"
line described a shape that file does not have — the upsert takes an
explicit process-wide write lock instead, and the read path takes none.
`useProjectSave` has no debounce; its only timer is a 2500 ms reset of the
"Saved" label. And the storage section now specifies the durable write
`migration_store` uses — fsync the file, rename, fsync the directory —
rather than `projects_store`'s bare rename, because notes are prose
nothing else holds a copy of.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HjL1E2JFNctUqCYotUwqqb
2026-09-01 12:00:52 -07:00
shadowdaoandClaude Opus 5 e58e2cdaf7 Design a per-project Notes tab with a send-to-agent action
Notes are discrete, addressable items with a button that puts one into a
running Claude session's prompt. That is deliberately not what
`claude_instructions` does — that field is *ambient*, merged into the
container's CLAUDE.md on every start and always in context. Nor is it a
`NOTES.md` in the workspace, which the agent can read but the user cannot,
once the container is stopped. Discrete items, fired on demand, readable
with the container down, is the gap neither of those covers.

Storage is one file per project under the app data dir, following
`migration_store.rs` rather than living on the `Project` record: that record
is rewritten on every blur by the debounced save path, so notes there would
mean the whole project list is rewritten per keystroke-batch and a note edit
could clobber a Config edit. `migration_store.rs` already documents that
reasoning for itself.

Two findings are worth more than the design they support.

**Newlines already have a verified answer.** A note body has newlines; typed
as raw keystrokes each one submits a separate prompt, so a note would arrive
as N truncated messages. `TerminalView.tsx` already sends `\x1b\r` for
Shift+Enter and its comment states those are the in-band bytes, not a guess,
with an explicit warning against simplifying to `\n` because a shell would
run the line. Send-to-agent reuses that sequence through one shared helper,
and — from the same comment — only offers `claude` sessions as targets,
since bash's readline has no binding for it and merely bells.

**The dock cannot widen the OS window.** A throwaway Tauri app was built and
run on KDE Plasma to find out, because the app has no window-geometry code to
reason from. Under XWayland every test passed exactly. Under native Wayland
the same binary asked +420 and got +600, moved the height +276 without being
asked, compounded that offset on every call, and ended reporting 5400x2900 on
a 4800x2700 monitor. Worse, `outer_position()` did not fail — it returned
`Ok(0,0)` for a window that was not at 0,0, so a "cannot determine position,
do not grow" fallback never fires. A clean failure could have been handled; a
plausible wrong answer cannot be detected from the value itself.

AppImages get XWayland because linuxdeploy-plugin-gtk forces GDK_BACKEND=x11;
the .deb and .rpm do not. The split is therefore by *packaging*, not platform
— two users on identical hardware would see different behavior. So the dock
takes space inward on every backend, which also costs nothing: the
ResizeObserver in `TerminalView.tsx` already reflows xterm and resizes the
container PTY on width change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HjL1E2JFNctUqCYotUwqqb
2026-09-01 11:48:31 -07:00
14 changed files with 3469 additions and 5 deletions
+1
View File
@@ -8,6 +8,7 @@ pub mod help_commands;
pub mod inspect_commands;
pub mod install_helper_commands;
pub mod migration_commands;
pub mod notes_commands;
pub mod project_commands;
pub mod settings_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.
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`
// below is what will be unreachable the moment this function drops the
// project record — see [`ProjectRemovalReport`] and
+4
View File
@@ -470,6 +470,10 @@ pub fn run() {
commands::project_commands::stop_project_container,
commands::project_commands::rebuild_project_container,
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
commands::migration_commands::get_container_staleness,
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 container_config;
pub mod gateway_settings;
pub mod migration;
pub mod note;
pub mod project;
pub mod settings_export;
pub mod update_info;
pub use project::*;
pub use container_config::*;
pub use app_settings::*;
pub use container_config::*;
pub use gateway_settings::*;
pub use migration::*;
pub use note::*;
pub use project::*;
pub use settings_export::*;
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 notes_store;
pub mod pending_cleanup;
pub mod projects_store;
pub mod secure;
+348
View File
@@ -0,0 +1,348 @@
//! 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 crate::models::Note;
/// Serialises the read-modify-write half of an upsert or delete.
///
/// Nothing here is cached, so there is no shared state to protect — but an
/// upsert reads the whole file, edits one entry and writes it back, and two of
/// those interleaving would lose whichever note was written first. The read
/// path does not take it.
fn write_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<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.
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 serde_json::from_str::<Vec<Note>>(&data) {
Ok(notes) => Ok(notes),
Err(e) => {
keep_corrupt_copy(&path, &chrono::Utc::now());
log::error!(
"Failed to parse notes for project {}: {} — treating as empty; the file is \
left in place and a copy was kept beside it",
project_id,
e
);
Ok(Vec::new())
}
}
}
fn keep_corrupt_copy(path: &Path, now: &chrono::DateTime<chrono::Utc>) {
let backup = path.with_extension(format!("json.corrupt-{}.bak", now.format("%Y%m%d-%H%M%S")));
if backup.exists() {
return;
}
if let Err(e) = fs::copy(path, &backup) {
log::error!("Could not keep a copy of the unreadable notes file: {}", e);
}
}
/// Insert or replace one note, leaving the rest untouched.
///
/// `created_at` and `id` are the store's, not the caller's: the webview sends
/// a whole `Note` back and must not be able to rewrite when a note was made.
/// `updated_at` is stamped here for the same reason.
fn upsert_in(dir: &Path, project_id: &str, mut note: Note) -> Result<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 data = serde_json::to_string_pretty(notes)
.map_err(|e| format!("Failed to serialize notes: {}", e))?;
let tmp = path.with_extension("json.tmp");
{
use std::io::Write;
let mut file =
fs::File::create(&tmp).map_err(|e| format!("Failed to write notes: {}", e))?;
file.write_all(data.as_bytes())
.map_err(|e| format!("Failed to write notes: {}", e))?;
file.sync_all()
.map_err(|e| format!("Failed to flush notes to disk: {}", e))?;
}
fs::rename(&tmp, &path).map_err(|e| format!("Failed to commit notes: {}", e))?;
sync_dir(&path);
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
}
#[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");
let copies: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.flatten()
.filter(|e| e.file_name().to_string_lossy().contains(".corrupt-"))
.collect();
assert_eq!(copies.len(), 1, "the bytes must be kept exactly once");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_write_leaves_no_temp_file_behind() {
let dir = temp_dir("tmp");
upsert_in(&dir, "p1", Note::new("t".into(), "b".into())).unwrap();
let leftovers: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.flatten()
.filter(|e| e.file_name().to_string_lossy().ends_with(".tmp"))
.collect();
assert!(leftovers.is_empty(), "the rename must have consumed the temp file");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn clearing_a_project_removes_its_file_and_missing_is_success() {
let dir = temp_dir("clear");
upsert_in(&dir, "p1", Note::new("t".into(), "b".into())).unwrap();
assert!(notes_path_in(&dir, "p1").exists());
clear_in(&dir, "p1").unwrap();
assert!(!notes_path_in(&dir, "p1").exists());
clear_in(&dir, "p1").unwrap(); // idempotent
std::fs::remove_dir_all(&dir).ok();
}
#[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();
}
}
+166
View File
@@ -0,0 +1,166 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import { useNotes } from "./useNotes";
import type { Note } from "../lib/types";
const listNotes = vi.fn();
const saveNote = vi.fn();
const deleteNote = vi.fn();
vi.mock("../lib/tauri-commands", () => ({
listNotes: (p: string) => listNotes(p),
saveNote: (p: string, n: Note) => saveNote(p, n),
deleteNote: (p: string, id: string) => deleteNote(p, id),
}));
const pushToast = vi.fn();
vi.mock("../store/appState", () => ({
useAppState: Object.assign(
(selector: (s: unknown) => unknown) => selector({ pushToast }),
{ getState: () => ({ pushToast }) },
),
}));
const note = (over: Partial<Note> = {}): Note => ({
id: "n1",
title: "Deploy",
body: "one\ntwo",
pinned: false,
created_at: "2026-09-01T00:00:00Z",
updated_at: "2026-09-01T00:00:00Z",
...over,
});
beforeEach(() => {
vi.clearAllMocks();
listNotes.mockResolvedValue([note()]);
saveNote.mockImplementation(async (_p: string, n: Note) => n);
deleteNote.mockResolvedValue(undefined);
});
describe("useNotes", () => {
it("loads a project's notes on mount", async () => {
const { result } = renderHook(() => useNotes("p1"));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(listNotes).toHaveBeenCalledWith("p1");
expect(result.current.notes).toHaveLength(1);
});
it("reports a failed save instead of swallowing it", async () => {
// Silent save failure is data loss: the user sees their text on screen and
// believes it is stored. Same reason `useSaveState` exists.
saveNote.mockRejectedValueOnce(new Error("disk full"));
const { result } = renderHook(() => useNotes("p1"));
await waitFor(() => expect(result.current.loading).toBe(false));
let ok: boolean | undefined;
await act(async () => {
ok = await result.current.saveNote(note({ body: "edited" }));
});
expect(ok).toBe(false);
expect(result.current.saveState.status).toBe("failed");
expect(pushToast).toHaveBeenCalled();
});
it("replaces the saved note in place rather than appending", async () => {
const { result } = renderHook(() => useNotes("p1"));
await waitFor(() => expect(result.current.loading).toBe(false));
// 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(pushToast).toHaveBeenCalled();
});
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);
});
});
+140
View File
@@ -0,0 +1,140 @@
import { useCallback, useEffect, useRef, useState } from "react";
import * as commands from "../lib/tauri-commands";
import type { Note } from "../lib/types";
import type { SaveState } from "./useSaveState";
import { useAppState } from "../store/appState";
/** A blank note, ordered to the top so the user can start typing immediately. */
function draft(): Note {
const now = new Date().toISOString();
return {
// The backend owns the real id; this one only has to be unique enough to
// key the list until the first save returns.
id: crypto.randomUUID(),
title: "",
body: "",
pinned: false,
created_at: now,
updated_at: now,
};
}
/**
* A project's notes, cached from the backend.
*
* The backend is the source of truth and this is a cache — every mutation goes
* through a command and the returned record replaces the local one, so the
* list can never drift from the file. `saveState` mirrors `useProjectSave` so
* `ui/SaveIndicator` can report the outcome: a save that fails silently is a
* user staring at text they believe is stored.
*/
export function useNotes(projectId: string) {
const [notes, setNotes] = useState<Note[]>([]);
const [loading, setLoading] = useState(true);
const [saveState, setSaveState] = useState<SaveState>({ status: "idle", error: null });
const pushToast = useAppState((s) => s.pushToast);
const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!projectId) {
setNotes([]);
setLoading(false);
return;
}
let cancelled = false;
setLoading(true);
setNotes([]);
commands
.listNotes(projectId)
.then((loaded) => {
if (!cancelled) setNotes(loaded);
})
.catch((e) => {
if (cancelled) return;
setNotes([]);
pushToast({
kind: "error",
message: "Could not load notes for this project",
detail: String(e),
});
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [projectId, pushToast]);
useEffect(
() => () => {
if (resetTimer.current) clearTimeout(resetTimer.current);
},
[],
);
const succeeded = useCallback(() => {
setSaveState({ status: "saved", error: null });
if (resetTimer.current) clearTimeout(resetTimer.current);
resetTimer.current = setTimeout(
() => setSaveState({ status: "idle", error: null }),
2500,
);
}, []);
const saveNote = useCallback(
async (note: Note) => {
if (resetTimer.current) clearTimeout(resetTimer.current);
setSaveState({ status: "saving", error: null });
try {
await commands.saveNote(projectId, note);
// Re-read the canonical list from the backend. A successful save stamps a new
// `updated_at`, and the backend sorts unpinned notes by `updated_at` descending,
// so the record's position has changed and positional patching would disagree with
// what a reload would show.
try {
const reloaded = await commands.listNotes(projectId);
setNotes(reloaded);
} catch {
// Keep the save reported as successful (it was) and leave the existing list alone
// rather than clearing it if the re-read fails.
}
succeeded();
return true;
} catch (e) {
const message = String(e);
setSaveState({ status: "failed", error: message });
pushToast({ kind: "error", message: "Could not save note", detail: message });
return false;
}
},
[projectId, pushToast, succeeded],
);
const createNote = useCallback(async () => {
const note = draft();
// Held locally first so the editor can focus it immediately; the save
// happens on blur like every other edit. The note does not exist backend-side,
// so no re-read can place it and no backend ordering applies to it yet. Prepending
// puts it at the top where the user can see it immediately, and on the first save
// its canonical position is established.
setNotes((current) => [note, ...current]);
return note;
}, []);
const deleteNote = useCallback(
async (noteId: string) => {
try {
await commands.deleteNote(projectId, noteId);
setNotes((current) => current.filter((n) => n.id !== noteId));
return true;
} catch (e) {
pushToast({ kind: "error", message: "Could not delete note", detail: String(e) });
return false;
}
},
[projectId, pushToast],
);
return { notes, loading, saveState, createNote, saveNote, deleteNote };
}
+10 -1
View File
@@ -1,5 +1,5 @@
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
export const checkDocker = () => invoke<boolean>("check_docker");
@@ -25,6 +25,15 @@ export const rebuildProjectContainer = (projectId: string) =>
export const reconcileProjectStatuses = () =>
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
export const getSettings = () => invoke<AppSettings>("get_settings");
export const updateSettings = (settings: AppSettings) =>
+10
View File
@@ -565,6 +565,16 @@ export interface SchedulerNotification {
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 ──────────────────────────────────────────────────────────────
/** Which loopback family the container-side listener was found on.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,349 @@
# 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.
### 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.
- 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.