Compare commits
10
Commits
b6ba6deb09
...
2708772bf9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2708772bf9 | ||
|
|
436b6dd470 | ||
|
|
5c47656444 | ||
|
|
be47c5edfd | ||
|
|
037ed78570 | ||
|
|
31e8f9df5f | ||
|
|
3704064006 | ||
|
|
f79a44e0a8 | ||
|
|
5a8e24ccbe | ||
|
|
a1f4eee9a3 |
@@ -15,8 +15,32 @@ use std::fs;
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::{Mutex, OnceLock};
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::models::Note;
|
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.
|
/// 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
|
/// Nothing here is cached, so there is no shared state to protect — but an
|
||||||
@@ -84,35 +108,139 @@ pub fn clear(project_id: &str) -> Result<(), String> {
|
|||||||
/// that project with no way out through the UI; deleting instead would destroy
|
/// 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
|
/// 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
|
/// corruption cannot overwrite the first — which is the one taken before
|
||||||
/// anything rewrote the file, and therefore the one worth having.
|
/// 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> {
|
fn load_in(dir: &Path, project_id: &str) -> Result<Vec<Note>, String> {
|
||||||
let path = notes_path_in(dir, project_id);
|
let path = notes_path_in(dir, project_id);
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
let data = fs::read_to_string(&path).map_err(|e| format!("Failed to read notes: {}", e))?;
|
let data = fs::read_to_string(&path).map_err(|e| format!("Failed to read notes: {}", e))?;
|
||||||
match serde_json::from_str::<Vec<Note>>(&data) {
|
match parse(&data) {
|
||||||
Ok(notes) => Ok(notes),
|
Ok(notes) => Ok(notes),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
keep_corrupt_copy(&path, &chrono::Utc::now());
|
let kept = keep_corrupt_copy(&path, &chrono::Utc::now());
|
||||||
log::error!(
|
log::error!(
|
||||||
"Failed to parse notes for project {}: {} — treating as empty; the file is \
|
"Failed to parse notes for project {}: {} — treating as empty; the file is \
|
||||||
left in place and a copy was kept beside it",
|
left in place{}",
|
||||||
project_id,
|
project_id,
|
||||||
e
|
e,
|
||||||
|
kept.describe()
|
||||||
);
|
);
|
||||||
Ok(Vec::new())
|
Ok(Vec::new())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn keep_corrupt_copy(path: &Path, now: &chrono::DateTime<chrono::Utc>) {
|
/// Parse a notes file: the versioned envelope, or a bare array.
|
||||||
let backup = path.with_extension(format!("json.corrupt-{}.bak", now.format("%Y%m%d-%H%M%S")));
|
///
|
||||||
if backup.exists() {
|
/// The bare array is what this store wrote before [`ProjectNotes`] existed —
|
||||||
return;
|
/// 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),
|
||||||
}
|
}
|
||||||
if let Err(e) = fs::copy(path, &backup) {
|
}
|
||||||
log::error!("Could not keep a copy of the unreadable notes file: {}", e);
|
|
||||||
|
/// 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()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,7 +298,11 @@ fn clear_in(dir: &Path, project_id: &str) -> Result<(), String> {
|
|||||||
/// prose the user typed and nothing else holds a copy.
|
/// prose the user typed and nothing else holds a copy.
|
||||||
fn save_all(dir: &Path, project_id: &str, notes: &[Note]) -> Result<(), String> {
|
fn save_all(dir: &Path, project_id: &str, notes: &[Note]) -> Result<(), String> {
|
||||||
let path = notes_path_in(dir, project_id);
|
let path = notes_path_in(dir, project_id);
|
||||||
let data = serde_json::to_string_pretty(notes)
|
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))?;
|
.map_err(|e| format!("Failed to serialize notes: {}", e))?;
|
||||||
let tmp = path.with_extension("json.tmp");
|
let tmp = path.with_extension("json.tmp");
|
||||||
|
|
||||||
@@ -220,6 +352,15 @@ mod tests {
|
|||||||
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]
|
#[test]
|
||||||
fn project_ids_cannot_escape_the_notes_directory() {
|
fn project_ids_cannot_escape_the_notes_directory() {
|
||||||
// The id arrives over IPC. It must not be able to steer the write.
|
// The id arrives over IPC. It must not be able to steer the write.
|
||||||
@@ -302,12 +443,116 @@ mod tests {
|
|||||||
assert_eq!(load_in(&dir, "p1").unwrap(), Vec::<Note>::new());
|
assert_eq!(load_in(&dir, "p1").unwrap(), Vec::<Note>::new());
|
||||||
assert!(path.exists(), "the unreadable file is left in place");
|
assert!(path.exists(), "the unreadable file is left in place");
|
||||||
|
|
||||||
let copies: Vec<_> = std::fs::read_dir(&dir)
|
assert_eq!(
|
||||||
.unwrap()
|
corrupt_copies(&dir).len(),
|
||||||
.flatten()
|
1,
|
||||||
.filter(|e| e.file_name().to_string_lossy().contains(".corrupt-"))
|
"the bytes must be kept exactly once"
|
||||||
.collect();
|
);
|
||||||
assert_eq!(copies.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();
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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/NotesPanel", () => ({
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 NotesPanel from "../notes/NotesPanel";
|
||||||
|
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 ? (
|
||||||
|
<NotesPanel 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import SendToAgentButton from "./SendToAgentButton";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectId: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
onTitleChange: (value: string) => void;
|
||||||
|
onBodyChange: (value: string) => void;
|
||||||
|
onCommit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Title and body, saved when a field loses focus.
|
||||||
|
*
|
||||||
|
* Plain text on purpose. There is no markdown rendering and no view/edit split,
|
||||||
|
* so there is no moment where the text on screen is not the text that would be
|
||||||
|
* sent — which is what makes "the agent gets exactly what you see" true rather
|
||||||
|
* than nearly true.
|
||||||
|
*/
|
||||||
|
export default function NoteEditor({
|
||||||
|
projectId,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
onTitleChange,
|
||||||
|
onBodyChange,
|
||||||
|
onCommit,
|
||||||
|
onDelete,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full min-h-0 gap-2 p-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => onTitleChange(e.target.value)}
|
||||||
|
onBlur={onCommit}
|
||||||
|
placeholder="Note title"
|
||||||
|
aria-label="Note title"
|
||||||
|
className="flex-1 min-w-0 px-2 h-8 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] text-[13px] text-[var(--text-primary)] focus:border-[var(--accent)] transition-colors"
|
||||||
|
/>
|
||||||
|
{/* The live editor text, not `note.body` — what is on screen is what
|
||||||
|
gets sent. */}
|
||||||
|
<SendToAgentButton projectId={projectId} body={body} />
|
||||||
|
<Button variant="danger" onClick={onDelete} aria-label="Delete note">
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={body}
|
||||||
|
onChange={(e) => onBodyChange(e.target.value)}
|
||||||
|
onBlur={onCommit}
|
||||||
|
placeholder="Reminders, gotchas, a prompt worth keeping…"
|
||||||
|
aria-label="Note body"
|
||||||
|
className="flex-1 min-h-0 w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] text-[13px] text-[var(--text-primary)] focus:border-[var(--accent)] resize-none font-mono transition-colors"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">
|
||||||
|
Notes save when a field loses focus. Sending puts the note in the agent’s
|
||||||
|
prompt — you press Enter.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, within, fireEvent, waitFor } from "@testing-library/react";
|
||||||
|
import NotesPanel from "./NotesPanel";
|
||||||
|
import { useAppState } from "../../store/appState";
|
||||||
|
import type { Note } from "../../lib/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two panels, one project — the configuration the app actually runs in.
|
||||||
|
*
|
||||||
|
* `NotesTab` and `NotesDock` both mount a `NotesPanel`, and the dock follows
|
||||||
|
* the active tab's project, so opening the dock over a Project Home tab mounts
|
||||||
|
* two panels 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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">
|
||||||
|
<NotesPanel 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("NotesPanel with the tab and the dock both open", () => {
|
||||||
|
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"));
|
||||||
|
|
||||||
|
fireEvent.click(dock().getByRole("button", { 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"));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useNotes } from "../../hooks/useNotes";
|
||||||
|
import NoteEditor from "./NoteEditor";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
import SaveIndicator from "../ui/SaveIndicator";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UNTITLED = "Untitled note";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The notes surface itself, shared by the Project Home tab and the dock so the
|
||||||
|
* two cannot drift into different behaviour.
|
||||||
|
*
|
||||||
|
* Master/detail: titles on the left, one editor on the right. The editor holds
|
||||||
|
* draft text locally and commits on blur, which is how every other editable
|
||||||
|
* field in the app behaves (`ClaudeInstructionsEditor`, the Config tab).
|
||||||
|
*/
|
||||||
|
export default function NotesPanel({ projectId }: Props) {
|
||||||
|
const { notes, loading, saveState, createNote, saveNote, deleteNote } =
|
||||||
|
useNotes(projectId);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [body, setBody] = useState("");
|
||||||
|
|
||||||
|
const selected = useMemo(
|
||||||
|
() => notes.find((n) => n.id === selectedId) ?? notes[0] ?? null,
|
||||||
|
[notes, selectedId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// What was last copied out of the store into the draft fields. The draft is
|
||||||
|
// "untouched" exactly while it still matches this, which is how an edit made
|
||||||
|
// somewhere else can be shown without ever discarding something half-typed.
|
||||||
|
const seeded = useRef<{ id: string | null; title: string; body: string }>({
|
||||||
|
id: null,
|
||||||
|
title: "",
|
||||||
|
body: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load the selected note's stored text into the draft — on a change of note,
|
||||||
|
// and on a change to the *stored* text of the note already selected. The
|
||||||
|
// second case is the dock and the tab showing one project at once: an edit
|
||||||
|
// committed in one surface has to reach the other's editor, not just its
|
||||||
|
// list. It never overwrites text the user is part-way through typing; that
|
||||||
|
// blurs into a last-writer-wins save, as any blur-commit editor does.
|
||||||
|
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 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCreate = async () => {
|
||||||
|
const note = await createNote();
|
||||||
|
if (note) setSelectedId(note.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<p className="p-4 text-xs text-[var(--text-secondary)]">Loading notes…</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full min-h-0">
|
||||||
|
<div className="flex items-center justify-between gap-2 px-3 py-2 border-b border-[var(--border-color)]">
|
||||||
|
<Button variant="primary" onClick={onCreate}>
|
||||||
|
New note
|
||||||
|
</Button>
|
||||||
|
<SaveIndicator state={saveState} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{notes.length === 0 ? (
|
||||||
|
<div className="flex-1 flex items-center justify-center p-4">
|
||||||
|
<p className="text-[13px] text-[var(--text-secondary)] text-center">
|
||||||
|
No notes yet. Keep reminders here, and send any of them straight to a
|
||||||
|
running Claude session.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 min-h-0 flex">
|
||||||
|
<ul className="w-48 flex-shrink-0 overflow-y-auto border-r border-[var(--border-color)] py-1">
|
||||||
|
{notes.map((n) => (
|
||||||
|
<li key={n.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedId(n.id)}
|
||||||
|
className={`w-full text-left px-3 py-1.5 text-xs truncate transition-colors ${
|
||||||
|
selected?.id === n.id
|
||||||
|
? "bg-[var(--bg-tertiary)] text-[var(--text-primary)]"
|
||||||
|
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{n.title.trim() || UNTITLED}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{selected && (
|
||||||
|
<NoteEditor
|
||||||
|
projectId={projectId}
|
||||||
|
title={title}
|
||||||
|
body={body}
|
||||||
|
onTitleChange={setTitle}
|
||||||
|
onBodyChange={setBody}
|
||||||
|
onCommit={commit}
|
||||||
|
onDelete={() => void deleteNote(selected.id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
|
import SendToAgentButton from "./SendToAgentButton";
|
||||||
|
import type { Project, TerminalSession } from "../../lib/types";
|
||||||
|
|
||||||
|
const sendInput = vi.fn(async () => {});
|
||||||
|
let sessions: TerminalSession[] = [];
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useTerminal", () => ({
|
||||||
|
useTerminal: () => ({ sessions, sendInput }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const setActiveTabKey = vi.fn();
|
||||||
|
const pushToast = vi.fn();
|
||||||
|
let projects: Project[] = [];
|
||||||
|
|
||||||
|
vi.mock("../../store/appState", () => ({
|
||||||
|
useAppState: Object.assign(
|
||||||
|
(selector: (s: unknown) => unknown) =>
|
||||||
|
selector({ projects, setActiveTabKey, pushToast }),
|
||||||
|
{ getState: () => ({ projects, setActiveTabKey, pushToast }) },
|
||||||
|
),
|
||||||
|
terminalTabKey: (id: string) => `term:${id}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const session = (over: Partial<TerminalSession> = {}): TerminalSession => ({
|
||||||
|
id: "s1",
|
||||||
|
projectId: "p1",
|
||||||
|
projectName: "api",
|
||||||
|
sessionType: "claude",
|
||||||
|
sessionName: null,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
sessions = [];
|
||||||
|
projects = [{ id: "p1", name: "api", renamed_session_names: {} } as unknown as Project];
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SendToAgentButton", () => {
|
||||||
|
it("is disabled when the project has no running session", () => {
|
||||||
|
render(<SendToAgentButton projectId="p1" body="hello" />);
|
||||||
|
expect(screen.getByRole("button", { name: /send to agent/i })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is disabled when the only session belongs to another project", () => {
|
||||||
|
sessions = [session({ projectId: "other" })];
|
||||||
|
render(<SendToAgentButton projectId="p1" body="hello" />);
|
||||||
|
expect(screen.getByRole("button", { name: /send to agent/i })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is disabled when the only session is a bash tab", () => {
|
||||||
|
// `bash -l`'s readline has no binding for ESC+CR and just bells, so a
|
||||||
|
// shell is never a target.
|
||||||
|
sessions = [session({ sessionType: "bash" })];
|
||||||
|
render(<SendToAgentButton projectId="p1" body="hello" />);
|
||||||
|
expect(screen.getByRole("button", { name: /send to agent/i })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends straight to the one session, with newlines converted and no terminator", async () => {
|
||||||
|
sessions = [session()];
|
||||||
|
render(<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=" " />);
|
||||||
|
expect(screen.getByRole("button", { name: /send to agent/i })).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useShallow } from "zustand/react/shallow";
|
||||||
|
import { useTerminal } from "../../hooks/useTerminal";
|
||||||
|
import { useAppState, terminalTabKey } from "../../store/appState";
|
||||||
|
import { toClaudePayload } from "../../lib/claudeInput";
|
||||||
|
import { sessionDisplayName } from "../../lib/sessionName";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectId: string;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Puts a note into a running Claude session's prompt.
|
||||||
|
*
|
||||||
|
* Three behaviours by target count: none disables the button, one sends
|
||||||
|
* straight there, several ask which. It never guesses — the note goes to a
|
||||||
|
* session the user named, or to the only one there is.
|
||||||
|
*
|
||||||
|
* Only `claude` sessions are offered. A bash tab would receive ESC+CR as an
|
||||||
|
* unbound readline key and answer with a bell (see `lib/claudeInput.ts`).
|
||||||
|
*/
|
||||||
|
export default function SendToAgentButton({ projectId, body }: Props) {
|
||||||
|
const { sessions, sendInput } = useTerminal();
|
||||||
|
const { projects, setActiveTabKey, pushToast } = useAppState(
|
||||||
|
useShallow((s) => ({
|
||||||
|
projects: s.projects,
|
||||||
|
setActiveTabKey: s.setActiveTabKey,
|
||||||
|
pushToast: s.pushToast,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const rootRef = useRef<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 disabled = targets.length === 0 || !hasBody;
|
||||||
|
|
||||||
|
// Same dismissal contract as `ui/OverflowMenu` and the tab context menu.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!menuOpen) return;
|
||||||
|
const onDocClick = (e: MouseEvent) => {
|
||||||
|
if (!rootRef.current?.contains(e.target as Node)) setMenuOpen(false);
|
||||||
|
};
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") setMenuOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", onDocClick);
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", onDocClick);
|
||||||
|
document.removeEventListener("keydown", onKey);
|
||||||
|
};
|
||||||
|
}, [menuOpen]);
|
||||||
|
|
||||||
|
const send = useCallback(
|
||||||
|
async (sessionId: string) => {
|
||||||
|
setMenuOpen(false);
|
||||||
|
try {
|
||||||
|
// No trailing CR: the note lands in the prompt and the user presses
|
||||||
|
// Enter. Newlines become ESC+CR so it arrives as one message rather
|
||||||
|
// than one prompt per line.
|
||||||
|
await sendInput(sessionId, toClaudePayload(body));
|
||||||
|
// A courtesy, not part of the send: if the tab cannot be focused the
|
||||||
|
// text still went.
|
||||||
|
setActiveTabKey(terminalTabKey(sessionId));
|
||||||
|
} catch (e) {
|
||||||
|
pushToast({
|
||||||
|
kind: "error",
|
||||||
|
message: "Could not send the note to the agent",
|
||||||
|
detail: String(e),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[body, sendInput, setActiveTabKey, pushToast],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onClick = useCallback(() => {
|
||||||
|
// The target is resolved at click time and pinned for the whole send, the
|
||||||
|
// hazard `useSTT` guards against by capturing its session at record start:
|
||||||
|
// the list can change while the request is in flight.
|
||||||
|
if (targets.length === 1) {
|
||||||
|
void send(targets[0].id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMenuOpen((open) => !open);
|
||||||
|
}, [targets, send]);
|
||||||
|
|
||||||
|
const title = !hasBody
|
||||||
|
? "Nothing to send — this note is empty"
|
||||||
|
: targets.length === 0
|
||||||
|
? "No running Claude session for this project"
|
||||||
|
: "Put this note into the agent's prompt (you press Enter)";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={rootRef} className="relative inline-block">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
disabled={disabled}
|
||||||
|
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 mt-1 min-w-[12rem] py-1 bg-[var(--bg-overlay)] border border-[var(--border-color)] rounded-[var(--radius-panel)] text-xs"
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
+290
-10
@@ -1,6 +1,7 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||||
import { useNotes } from "./useNotes";
|
import { useNotes } from "./useNotes";
|
||||||
|
import { useAppState } from "../store/appState";
|
||||||
import type { Note } from "../lib/types";
|
import type { Note } from "../lib/types";
|
||||||
|
|
||||||
const listNotes = vi.fn();
|
const listNotes = vi.fn();
|
||||||
@@ -13,14 +14,6 @@ vi.mock("../lib/tauri-commands", () => ({
|
|||||||
deleteNote: (p: string, id: string) => deleteNote(p, id),
|
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 => ({
|
const note = (over: Partial<Note> = {}): Note => ({
|
||||||
id: "n1",
|
id: "n1",
|
||||||
title: "Deploy",
|
title: "Deploy",
|
||||||
@@ -31,8 +24,35 @@ const note = (over: Partial<Note> = {}): Note => ({
|
|||||||
...over,
|
...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(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
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()]);
|
listNotes.mockResolvedValue([note()]);
|
||||||
saveNote.mockImplementation(async (_p: string, n: Note) => n);
|
saveNote.mockImplementation(async (_p: string, n: Note) => n);
|
||||||
deleteNote.mockResolvedValue(undefined);
|
deleteNote.mockResolvedValue(undefined);
|
||||||
@@ -60,7 +80,7 @@ describe("useNotes", () => {
|
|||||||
|
|
||||||
expect(ok).toBe(false);
|
expect(ok).toBe(false);
|
||||||
expect(result.current.saveState.status).toBe("failed");
|
expect(result.current.saveState.status).toBe("failed");
|
||||||
expect(pushToast).toHaveBeenCalled();
|
expect(toasts()).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("replaces the saved note in place rather than appending", async () => {
|
it("replaces the saved note in place rather than appending", async () => {
|
||||||
@@ -128,7 +148,7 @@ describe("useNotes", () => {
|
|||||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
|
||||||
expect(result.current.notes).toHaveLength(0);
|
expect(result.current.notes).toHaveLength(0);
|
||||||
expect(pushToast).toHaveBeenCalled();
|
expect(toasts()).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ends with the list the backend returned when saving a new note", async () => {
|
it("ends with the list the backend returned when saving a new note", async () => {
|
||||||
@@ -163,4 +183,264 @@ describe("useNotes", () => {
|
|||||||
// listNotes should be called again after the save
|
// listNotes should be called again after the save
|
||||||
expect(listNotes).toHaveBeenCalledTimes(callCountBefore + 1);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+283
-73
@@ -8,8 +8,8 @@ import { useAppState } from "../store/appState";
|
|||||||
function draft(): Note {
|
function draft(): Note {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
return {
|
return {
|
||||||
// The backend owns the real id; this one only has to be unique enough to
|
// The backend keeps whatever id it is handed for a note it has not seen,
|
||||||
// key the list until the first save returns.
|
// so this one is the note's real id from the first save onward.
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
title: "",
|
title: "",
|
||||||
body: "",
|
body: "",
|
||||||
@@ -19,39 +19,189 @@ function draft(): Note {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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.
|
* A project's notes, cached from the backend.
|
||||||
*
|
*
|
||||||
* The backend is the source of truth and this is a cache — every mutation goes
|
* The backend is the source of truth and the zustand slice is the cache —
|
||||||
* through a command and the returned record replaces the local one, so the
|
* every mutation goes through a command and the returned list replaces the
|
||||||
* list can never drift from the file. `saveState` mirrors `useProjectSave` so
|
* cached one, so the list can never drift from the file. The cache lives in
|
||||||
* `ui/SaveIndicator` can report the outcome: a save that fails silently is a
|
* the store rather than in this hook because two surfaces show the same
|
||||||
* user staring at text they believe is stored.
|
* 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) {
|
export function useNotes(projectId: string) {
|
||||||
const [notes, setNotes] = useState<Note[]>([]);
|
const cached = useAppState((s) => s.notesByProject[projectId]);
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [saveState, setSaveState] = useState<SaveState>({ status: "idle", error: null });
|
|
||||||
const pushToast = useAppState((s) => s.pushToast);
|
const pushToast = useAppState((s) => s.pushToast);
|
||||||
|
const [saveState, setSaveState] = useState<SaveState>({ status: "idle", error: null });
|
||||||
const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const currentProjectId = useRef(projectId);
|
||||||
|
currentProjectId.current = projectId;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!projectId) {
|
if (!projectId) return;
|
||||||
setNotes([]);
|
// Read through `getState` rather than through subscribed values: the
|
||||||
setLoading(false);
|
// effect must fire once per project, not again every time the flag it sets
|
||||||
return;
|
// changes. Two panels mounting for the same project therefore make one
|
||||||
}
|
// read, and the second renders from the cache with no loading flash.
|
||||||
let cancelled = false;
|
const store = useAppState.getState();
|
||||||
setLoading(true);
|
if (store.notesLoading[projectId]) return;
|
||||||
setNotes([]);
|
store.setNotesLoading(projectId, true);
|
||||||
|
const seq = issueNotesWrite(projectId);
|
||||||
commands
|
commands
|
||||||
.listNotes(projectId)
|
.listNotes(projectId)
|
||||||
.then((loaded) => {
|
.then((loaded) => {
|
||||||
if (!cancelled) setNotes(loaded);
|
commitNotes(projectId, seq, loaded);
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
if (cancelled) return;
|
// A project that has never been read caches the empty list, so a panel
|
||||||
setNotes([]);
|
// 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({
|
pushToast({
|
||||||
kind: "error",
|
kind: "error",
|
||||||
message: "Could not load notes for this project",
|
message: "Could not load notes for this project",
|
||||||
@@ -59,11 +209,8 @@ export function useNotes(projectId: string) {
|
|||||||
});
|
});
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!cancelled) setLoading(false);
|
useAppState.getState().setNotesLoading(projectId, false);
|
||||||
});
|
});
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [projectId, pushToast]);
|
}, [projectId, pushToast]);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
@@ -73,6 +220,27 @@ export function useNotes(projectId: string) {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 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(() => {
|
const succeeded = useCallback(() => {
|
||||||
setSaveState({ status: "saved", error: null });
|
setSaveState({ status: "saved", error: null });
|
||||||
if (resetTimer.current) clearTimeout(resetTimer.current);
|
if (resetTimer.current) clearTimeout(resetTimer.current);
|
||||||
@@ -83,58 +251,100 @@ export function useNotes(projectId: string) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const saveNote = useCallback(
|
const saveNote = useCallback(
|
||||||
async (note: Note) => {
|
(note: Note) =>
|
||||||
if (resetTimer.current) clearTimeout(resetTimer.current);
|
enqueueMutation(projectId, async () => {
|
||||||
setSaveState({ status: "saving", error: null });
|
if (isCurrent()) {
|
||||||
try {
|
if (resetTimer.current) clearTimeout(resetTimer.current);
|
||||||
await commands.saveNote(projectId, note);
|
setSaveState({ status: "saving", error: null });
|
||||||
// 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();
|
try {
|
||||||
return true;
|
await commands.saveNote(projectId, note);
|
||||||
} catch (e) {
|
await refresh(projectId);
|
||||||
const message = String(e);
|
if (isCurrent()) succeeded();
|
||||||
setSaveState({ status: "failed", error: message });
|
return true;
|
||||||
pushToast({ kind: "error", message: "Could not save note", detail: message });
|
} catch (e) {
|
||||||
return false;
|
const message = String(e);
|
||||||
}
|
if (isCurrent()) setSaveState({ status: "failed", error: message });
|
||||||
},
|
// The toast is not project-scoped — it names the failure and stays
|
||||||
[projectId, pushToast, succeeded],
|
// 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],
|
||||||
);
|
);
|
||||||
|
|
||||||
const createNote = useCallback(async () => {
|
/**
|
||||||
const note = draft();
|
* Create a note by persisting it, rather than holding it locally until the
|
||||||
// Held locally first so the editor can focus it immediately; the save
|
* first blur.
|
||||||
// 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
|
* The draft used to live only in the list, which meant any *other* note
|
||||||
// puts it at the top where the user can see it immediately, and on the first save
|
* being saved replaced the list with the backend's and the unsaved draft
|
||||||
// its canonical position is established.
|
* silently vanished — click "New note" twice, type in the second, blur, and
|
||||||
setNotes((current) => [note, ...current]);
|
* the first row is gone. Sharing one cache between two surfaces makes that
|
||||||
return note;
|
* 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
|
||||||
const deleteNote = useCallback(
|
* list that the file does not have.
|
||||||
async (noteId: string) => {
|
*/
|
||||||
try {
|
const createNote = useCallback(
|
||||||
await commands.deleteNote(projectId, noteId);
|
() =>
|
||||||
setNotes((current) => current.filter((n) => n.id !== noteId));
|
enqueueMutation(projectId, async () => {
|
||||||
return true;
|
const note = draft();
|
||||||
} catch (e) {
|
try {
|
||||||
pushToast({ kind: "error", message: "Could not delete note", detail: String(e) });
|
const saved = await commands.saveNote(projectId, note);
|
||||||
return false;
|
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],
|
[projectId, pushToast],
|
||||||
);
|
);
|
||||||
|
|
||||||
return { notes, loading, saveState, createNote, saveNote, deleteNote };
|
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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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)" : "")
|
||||||
|
);
|
||||||
|
}
|
||||||
+119
-1
@@ -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
|
||||||
@@ -98,6 +153,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 +205,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;
|
||||||
@@ -340,6 +425,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 +497,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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -85,6 +85,13 @@ struct ProjectNotes { version: u32, notes: Vec<Note> }
|
|||||||
|
|
||||||
Order is pinned-first then `updated_at` descending. Manual reordering is deliberately out.
|
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`
|
### Why not a field on `Project`
|
||||||
|
|
||||||
`projects.json` is written on **every blur** by the debounced `useProjectSave` path
|
`projects.json` is written on **every blur** by the debounced `useProjectSave` path
|
||||||
@@ -339,6 +346,9 @@ that — but anything touching real key handling needs a manual check in Chromiu
|
|||||||
generic write-a-file-to-container command today (only `write_file_to_container` for image
|
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
|
paste and `upload_bytes_to_container` for migration), and a second storage path with a
|
||||||
sync direction is a v2 conversation.
|
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.
|
- Tags, full-text search, manual reordering, note history.
|
||||||
- Any change to `claude_instructions`. The two features stay distinct: ambient context
|
- Any change to `claude_instructions`. The two features stay distinct: ambient context
|
||||||
versus fired-on-demand items.
|
versus fired-on-demand items.
|
||||||
|
|||||||
Reference in New Issue
Block a user