Compare commits

...
10 Commits
Author SHA1 Message Date
shadowdaoandClaude Opus 5 2708772bf9 Order the notes cache by sequence, not by who resolves last
Secret Scan / scan (push) Successful in 4s
Build App (Preview) / compute-version (pull_request) Successful in 5s
Secret Scan / scan (pull_request) Successful in 6s
Build App (Preview) / create-release (pull_request) Successful in 4s
Build App (Preview) / build-macos (pull_request) Successful in 2m48s
Build App (Preview) / build-linux (pull_request) Successful in 6m2s
Build App (Preview) / build-windows (pull_request) Successful in 6m4s
Build App (Preview) / prune-previews (pull_request) Successful in 4s
One gesture puts two requests in flight. With the tab already loaded, clicking
the dock toggle while the textarea has focus fires `blur` -> `saveNote` and the
dock's mount -> `list_notes` in the same tick. The save finishes and its re-read
writes the post-save list; the mount's read -- issued earlier, still out -- then
lands its pre-save snapshot on top, and both panels show stale text until
something else refreshes.

`mutationChains` could not have caught this: it orders a project's writes
against each other and the mount load is a read outside it. Putting the read on
the chain would work, but it buys correctness with latency the user feels -- a
panel mount waiting behind `save_note`'s double-fsync write -- and leaves a
"mutation chain" holding reads.

The two requests are not competing for a resource; the loser's result is simply
older. So every write into `notesByProject[p]` now claims a per-project sequence
when the request behind it is issued, and `commitNotes` drops one whose sequence
predates what is already cached. Reads take their sequence at issue time, since
being ordered by resolution is the bug. Local patches -- the filter behind a
confirmed delete, the prepend behind a failed re-read -- take a fresh one at
commit time, because they are authoritative then rather than derived from an
earlier read, and anything still in flight behind them is genuinely stale. A
failed read commits under its *own* sequence, not a fresh one, so its empty list
cannot beat a later read that has the real answer.

`isCurrent()` stays, and is not folded in. It guards `setSaveState`, not the
cache: it asks whether this *panel* is still showing the project a save was made
for, which a per-project counter cannot answer -- two panels on one project share
every sequence value. Ordering and panel identity are two questions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HjL1E2JFNctUqCYotUwqqb
2026-09-01 14:23:55 -07:00
shadowdaoandClaude Opus 5 436b6dd470 Send a lone CR through the newline transform, and say what pinned is
`toClaudePayload` matched `/\r?\n/`, so a bare CR that is not part of a
CRLF went through verbatim — and a bare CR *submits* in a Claude prompt
and *runs* the line in a shell, which is the terminator the function's
own contract says it never appends. A `<textarea>` cannot produce one,
but `load_in` returns whatever a hand-edited or externally written notes
file holds, so the guarantee has to cover that rather than only what the
editor can type.

`Note.pinned` is persisted and sorted on, but nothing in the app sets
it: there is no pin control and no indicator. The spec stated the
ordering rule as though pinning existed and §8 did not list it, so the
spec is amended to say `pinned` is reserved and inert in v1, and pinning
is added to the out-of-scope list. No UI is added — a user-facing
affordance does not belong in a fix wave.

Also renamed NotesPanel.test.tsx's "deletes the selected note and falls
back to another": `useNotes` is mocked in that file and the mocked list
never changes, so the fallback was never exercised. A name that claims
coverage which is absent is worse than an absent test, because it makes
the gap invisible. The real assertion now lives against the real hook in
NotesPanel.shared.test.tsx.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HjL1E2JFNctUqCYotUwqqb
2026-09-01 13:47:01 -07:00
shadowdaoandClaude Opus 5 5c47656444 Cache notes in one place, and serialise a project's writes
Implements the design spec's §2 — "notes cached in zustand keyed by
project id" — which the plan substituted with a hook-local `useState`.

Sharing the `NotesPanel` *component* between the Project Home sub-tab and
the dock did not share the *cache*. Both resolve to the same project, so
two panels mount two `useNotes(P)`, each with its own list. Edit a note
in the dock and blur; the tab's copy is still pre-edit, and the tab's
next blur commits `{...staleRecord, title, body}` — the dock's edit gone
from disk with no error and no indicator. That is the feature's own
primary workflow: take notes in the dock while the agent runs, which is
the reason the dock exists, then go back to the tab.

`notesByProject` plus a per-project in-flight flag now hold the list.
Both surfaces render from one array; two panels mounting for one project
make one read; and because the write is keyed by project, a response
that lands after the user has moved on updates the project it belongs to
rather than whichever is on screen. This is also the boundary §8 says a
detached notes window needs.

Three more bugs in the same code, fixed with it:

- Delete-after-edit could resurrect the note. Clicking Delete with the
  textarea focused fires blur first, so `save_note` and `delete_note` go
  out back to back; Rust's `write_lock` stops them interleaving but does
  not order them, and a delete that wins the lock is undone by the
  upsert behind it. A project's mutations now go through one promise
  chain, module-scoped for the reason `useTerminal`'s input queue is.
- An unsaved draft vanished when any other note was saved, because the
  re-read replaced the list with the backend's. "New note" now persists,
  so the backend owns the row from the start — chosen over merging local
  drafts because a local-only row in a *shared* cache would exist in the
  panel that made it and nowhere else.
- The save outcome was reported for the wrong project after a switch:
  the guard covered only the list replacement, so the new project's
  SaveIndicator flashed "Saved ✓" for the old project's write. The
  indicator now resets on a project change and reports only its own.

`NotesPanel` also re-seeds its draft when the *stored* text of the note
it has selected changes, so an edit made in the other surface reaches
the editor and not only the list. It never overwrites something
half-typed; that still blurs into a last-writer-wins save, as any
blur-commit editor does.

NotesPanel.shared.test.tsx is the configuration none of the existing
tests had: two panels, one project, the real hook. Four of its six
assertions fail against the previous implementation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HjL1E2JFNctUqCYotUwqqb
2026-09-01 13:47:01 -07:00
shadowdaoandClaude Opus 5 be47c5edfd Cap the corrupt-notes copies and put a version envelope on disk
Two things the plan dropped from the design spec's §1.

`keep_corrupt_copy`'s only guard was "does this second's copy already
exist", so a persistently unparseable file minted a full copy of the
user's prose every time the clock ticked over — and `list_notes` runs on
*every* NotesPanel mount, i.e. every project switch, every
dock-follows-tab change, every sub-tab toggle. A minute of clicking
between two projects was ~60 copies. `MAX_CORRUPT_BACKUPS`,
`corrupt_backups_full()` and the three-outcome `Kept` enum come across
from `migration_store` whole, including the reason the cap is asked
*before* the copy (so it is not implemented by writing a file and
deleting it again, and so the surviving copies are the oldest ones) and
the reason the log line must not claim a backup that was never written.

The file itself is now `{ version, notes }` rather than a bare array.
It costs nothing today and gets permanently more expensive once files
exist in the field. No released build has written notes, so there is no
migration path — but a bare array is still *read*, because declaring a
perfectly readable file corrupt is the one outcome this store exists to
avoid, and a developer's own notes are prose nothing else has a copy of.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HjL1E2JFNctUqCYotUwqqb
2026-09-01 13:40:40 -07:00
shadowdao 037ed78570 Test the dock's load-path clamp and keyboard resize direction
- notesDockWidth store initialization now clamps/defaults a bad
  localStorage value on load, not just on write (verified this fails
  without the clamp).
- The keyboard resize test asserts the exact widened/narrowed value
  instead of just that the setter was called, so a swapped or
  inverted arrow-key branch would be caught.
2026-09-01 13:22:35 -07:00
shadowdao 31e8f9df5f Add the notes dock 2026-09-01 13:15:13 -07:00
shadowdao 3704064006 Add the Notes tab 2026-09-01 13:08:17 -07:00
shadowdao f79a44e0a8 Add the send-to-agent button 2026-09-01 13:02:22 -07:00
shadowdao 5a8e24ccbe Extract the Claude newline sequence and the session display name 2026-09-01 12:57:29 -07:00
shadowdao a1f4eee9a3 Fix critical cross-project data corruption bug in useNotes hook
When a save is in flight for project A and the user switches to project B before it resolves, the stale closure still has projectId=A. When A's save resolves, the post-save re-read of listNotes(projectId) runs with the stale closed-over projectId, and setNotes(reloaded) overwrites B's displayed notes with A's list—the same cross-project contamination class as Finding 2 but reintroduced through the fix itself.

Fix: Add a currentProjectId ref updated on every render, and guard both saveNote and deleteNote callbacks with a check before replacing/filtering the whole list. If the project changed while the async operation was in flight, bail out of the state update but still report success (the operation itself succeeded on the backend; only the stale list update is skipped).

Added test: a save in flight for one project, a switch to another, then the first save resolving—asserts the second project's notes are still displayed.
2026-09-01 12:50:23 -07:00
24 changed files with 2185 additions and 116 deletions
+263 -18
View File
@@ -15,8 +15,32 @@ use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use serde::{Deserialize, Serialize};
use crate::models::Note;
/// The version stamped into every notes file this build writes.
const NOTES_FORMAT_VERSION: u32 = 1;
/// What is actually on disk: a version envelope around the notes.
///
/// The list is wrapped rather than written bare because the wrapper costs
/// nothing today and cannot be added cheaply later — once files exist in the
/// field, every reader has to sniff two shapes forever. `version` is written
/// and read back but nothing branches on it yet: it is the hook a future
/// format change hangs off, and its value is only useful if it has been there
/// since the first file.
///
/// Not in `models/` and not exposed over IPC: the frontend receives
/// `Vec<Note>` from `list_notes` and never sees the envelope, so this is a
/// storage detail rather than part of the IPC contract.
#[derive(Debug, Serialize, Deserialize)]
struct ProjectNotes {
version: u32,
#[serde(default)]
notes: Vec<Note>,
}
/// Serialises the read-modify-write half of an upsert or delete.
///
/// Nothing here is cached, so there is no shared state to protect — but an
@@ -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
/// the only copy of what the user wrote. The copy is timestamped so a second
/// corruption cannot overwrite the first — which is the one taken before
/// anything rewrote the file, and therefore the one worth having.
/// anything rewrote the file, and therefore the one worth having — and capped,
/// because `list_notes` runs on *every* panel mount. See [`keep_corrupt_copy`].
fn load_in(dir: &Path, project_id: &str) -> Result<Vec<Note>, String> {
let path = notes_path_in(dir, project_id);
if !path.exists() {
return Ok(Vec::new());
}
let data = fs::read_to_string(&path).map_err(|e| format!("Failed to read notes: {}", e))?;
match serde_json::from_str::<Vec<Note>>(&data) {
match parse(&data) {
Ok(notes) => Ok(notes),
Err(e) => {
keep_corrupt_copy(&path, &chrono::Utc::now());
let kept = keep_corrupt_copy(&path, &chrono::Utc::now());
log::error!(
"Failed to parse notes for project {}: {} — treating as empty; the file is \
left in place and a copy was kept beside it",
left in place{}",
project_id,
e
e,
kept.describe()
);
Ok(Vec::new())
}
}
}
fn keep_corrupt_copy(path: &Path, now: &chrono::DateTime<chrono::Utc>) {
let backup = path.with_extension(format!("json.corrupt-{}.bak", now.format("%Y%m%d-%H%M%S")));
if backup.exists() {
return;
/// Parse a notes file: the versioned envelope, or a bare array.
///
/// The bare array is what this store wrote before [`ProjectNotes`] existed —
/// only ever on a development build, but a developer's own notes are still
/// prose nothing else holds a copy of, and the alternative is `load_in`
/// declaring a perfectly readable file corrupt. It is read, never written: the
/// first save rewrites the file with an envelope.
fn parse(data: &str) -> Result<Vec<Note>, serde_json::Error> {
match serde_json::from_str::<ProjectNotes>(data) {
Ok(file) => Ok(file.notes),
// Report the envelope's error, not the array's — the envelope is the
// shape this store writes, so its message is the one that describes
// what is actually wrong with the file.
Err(envelope_err) => serde_json::from_str::<Vec<Note>>(data).map_err(|_| envelope_err),
}
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.
fn save_all(dir: &Path, project_id: &str, notes: &[Note]) -> Result<(), String> {
let path = notes_path_in(dir, project_id);
let data = serde_json::to_string_pretty(notes)
let file = ProjectNotes {
version: NOTES_FORMAT_VERSION,
notes: notes.to_vec(),
};
let data = serde_json::to_string_pretty(&file)
.map_err(|e| format!("Failed to serialize notes: {}", e))?;
let tmp = path.with_extension("json.tmp");
@@ -220,6 +352,15 @@ mod tests {
dir
}
fn corrupt_copies(dir: &std::path::Path) -> Vec<String> {
std::fs::read_dir(dir)
.unwrap()
.flatten()
.map(|e| e.file_name().to_string_lossy().to_string())
.filter(|n| n.contains(".corrupt-"))
.collect()
}
#[test]
fn project_ids_cannot_escape_the_notes_directory() {
// The id arrives over IPC. It must not be able to steer the write.
@@ -302,12 +443,116 @@ mod tests {
assert_eq!(load_in(&dir, "p1").unwrap(), Vec::<Note>::new());
assert!(path.exists(), "the unreadable file is left in place");
let copies: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.flatten()
.filter(|e| e.file_name().to_string_lossy().contains(".corrupt-"))
.collect();
assert_eq!(copies.len(), 1, "the bytes must be kept exactly once");
assert_eq!(
corrupt_copies(&dir).len(),
1,
"the bytes must be kept exactly once"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn what_is_written_is_a_version_envelope_not_a_bare_array() {
// The envelope costs nothing now and cannot be added cheaply once
// files exist in the field, so the very first file has to carry it.
let dir = temp_dir("envelope");
upsert_in(&dir, "p1", Note::new("t".into(), "b".into())).unwrap();
let raw = std::fs::read_to_string(notes_path_in(&dir, "p1")).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
assert_eq!(parsed["version"], NOTES_FORMAT_VERSION);
assert_eq!(parsed["notes"].as_array().unwrap().len(), 1);
assert_eq!(parsed["notes"][0]["body"], "b");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_pre_envelope_bare_array_still_reads_and_is_not_called_corrupt() {
// Only a development build ever wrote this shape, but declaring a
// perfectly readable file corrupt is the one outcome this store exists
// to avoid. It is read, never written back.
let dir = temp_dir("legacy");
let note = Note::new("Deploy".into(), "one\ntwo".into());
std::fs::write(
notes_path_in(&dir, "p1"),
serde_json::to_string(&vec![note.clone()]).unwrap(),
)
.unwrap();
let loaded = load_in(&dir, "p1").unwrap();
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].body, "one\ntwo");
let copies = corrupt_copies(&dir);
assert!(copies.is_empty(), "a readable file must not be copied aside");
// The next write upgrades it in place.
upsert_in(&dir, "p1", note).unwrap();
let raw = std::fs::read_to_string(notes_path_in(&dir, "p1")).unwrap();
assert!(raw.contains("\"version\""));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn corrupt_copies_are_capped_rather_than_one_per_second() {
// `list_notes` runs on every panel mount, so an unrepaired file would
// otherwise mint a full copy of the user's prose every time the
// clock's second changed.
let dir = temp_dir("cap");
let path = notes_path_in(&dir, "p1");
std::fs::write(&path, b"{ not json").unwrap();
let base = chrono::Utc::now();
for i in 0..MAX_CORRUPT_BACKUPS as i64 + 3 {
let at = base + chrono::Duration::seconds(i);
let kept = keep_corrupt_copy(&path, &at);
if i < MAX_CORRUPT_BACKUPS as i64 {
assert!(matches!(kept, Kept::Copied(_)), "copy {} should be kept", i);
} else {
assert!(
matches!(kept, Kept::EnoughAlready(MAX_CORRUPT_BACKUPS)),
"copy {} should be refused by the cap",
i
);
}
}
assert_eq!(corrupt_copies(&dir).len(), MAX_CORRUPT_BACKUPS);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_second_read_in_the_same_second_does_not_re_copy() {
let dir = temp_dir("samesecond");
let path = notes_path_in(&dir, "p1");
std::fs::write(&path, b"{ not json").unwrap();
let at = chrono::Utc::now();
assert!(matches!(keep_corrupt_copy(&path, &at), Kept::Copied(_)));
assert!(matches!(
keep_corrupt_copy(&path, &at),
Kept::AlreadyThere(_)
));
assert_eq!(corrupt_copies(&dir).len(), 1);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn the_log_line_never_claims_a_backup_that_was_not_written() {
// A message that invents a backup is worse than no message: it is what
// someone reads before going to look for their data.
let dir = temp_dir("honesty");
let path = notes_path_in(&dir, "p1");
std::fs::write(&path, b"{ not json").unwrap();
let copied = keep_corrupt_copy(&path, &chrono::Utc::now()).describe();
assert!(copied.contains("a copy is at"));
let refused = Kept::EnoughAlready(MAX_CORRUPT_BACKUPS).describe();
assert!(refused.contains("no copy kept"));
assert!(!refused.contains("a copy is at"));
let failed = Kept::Failed("permission denied".into()).describe();
assert!(failed.contains("could not keep a copy"));
assert!(!failed.contains("a copy is at"));
std::fs::remove_dir_all(&dir).ok();
}
+2
View File
@@ -4,6 +4,7 @@ import { listen } from "@tauri-apps/api/event";
import Sidebar from "./components/layout/Sidebar";
import TopBar from "./components/layout/TopBar";
import StatusBar from "./components/layout/StatusBar";
import NotesDock from "./components/layout/NotesDock";
import TerminalView from "./components/terminal/TerminalView";
import DockerInstallDialog from "./components/DockerInstallDialog";
import ProjectHome from "./components/projects/home/ProjectHome";
@@ -161,6 +162,7 @@ export default function App() {
</div>
)}
</main>
<NotesDock />
</div>
<StatusBar stt={stt} />
<ToastHost />
+6 -12
View File
@@ -10,6 +10,7 @@ import {
} from "../../store/appState";
import { effectivePermissionMode } from "../projects/PermissionModeControl";
import { ProjectStatusIndicator } from "../ui/StatusIndicator";
import { sessionDisplayName } from "../../lib/sessionName";
import type { PermissionMode } from "../../lib/types";
interface ContextMenuState {
@@ -195,11 +196,10 @@ export default function MainTabs() {
}
const session = sessions.find((s) => s.id === tabKeyId(key));
if (!session) return "";
const custom = getCustomName(session.projectId, session.id);
return custom
? `${session.projectName}: ${custom}`
: (session.sessionName ?? session.projectName) +
(session.sessionType === "bash" ? " (bash)" : "");
return sessionDisplayName(
session,
projects.find((p) => p.id === session.projectId),
);
};
const endDrag = () => {
@@ -358,13 +358,7 @@ export default function MainTabs() {
const session = sessions.find((s) => s.id === sessionId);
if (!session) return null;
const project = projects.find((p) => p.id === session.projectId);
const customName = getCustomName(session.projectId, session.id);
const baseLabel =
(session.sessionName ?? session.projectName) +
(session.sessionType === "bash" ? " (bash)" : "");
const displayLabel = customName
? `${session.projectName}: ${customName}`
: baseLabel;
const displayLabel = sessionDisplayName(session, project);
const isRenaming = renamingId === session.id;
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);
});
});
+128
View File
@@ -0,0 +1,128 @@
import { useShallow } from "zustand/react/shallow";
import {
useAppState,
isHomeTab,
isTerminalTab,
tabKeyId,
NOTES_DOCK_MIN_WIDTH,
NOTES_DOCK_MAX_WIDTH,
} from "../../store/appState";
import 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>
);
}
+11 -1
View File
@@ -10,7 +10,7 @@ interface Props {
export default function StatusBar({ stt }: Props) {
const {
projects, sessions, terminalHasSelection, activeSessionId, sttEnabled,
terminalAtBottom, scrollActiveToBottom,
terminalAtBottom, scrollActiveToBottom, notesDockOpen, toggleNotesDock,
} = useAppState(
useShallow(s => ({
projects: s.projects,
@@ -20,6 +20,8 @@ export default function StatusBar({ stt }: Props) {
sttEnabled: s.appSettings?.stt?.enabled,
terminalAtBottom: s.terminalAtBottom,
scrollActiveToBottom: s.scrollActiveToBottom,
notesDockOpen: s.notesDockOpen,
toggleNotesDock: s.toggleNotesDock,
}))
);
const running = projects.filter((p) => p.status === "running").length;
@@ -69,6 +71,14 @@ export default function StatusBar({ stt }: Props) {
Jump to Current
</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 && (
<SttButton
state={stt.state}
+63
View File
@@ -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&rsquo;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"));
});
});
+137
View File
@@ -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 FilesTab from "./FilesTab";
import BrowserTab from "./BrowserTab";
import NotesTab from "./NotesTab";
import { formatUptime } from "./format";
import { describeLeftovers, leftoverPronoun, leftoverVerb } from "./removalReport";
@@ -28,6 +29,7 @@ const TABS = [
{ id: "config", label: "Config" },
{ id: "files", label: "Files" },
{ id: "browser", label: "Browser" },
{ id: "notes", label: "Notes" },
] as const;
export type ProjectHomeTabId = (typeof TABS)[number]["id"];
@@ -255,6 +257,7 @@ export default function ProjectHome({ projectId, active }: Props) {
{tab === "browser" && (
<BrowserTab project={project} active={active && tab === "browser"} />
)}
{tab === "notes" && <NotesTab project={project} />}
</div>
{showMigration && (
+2 -1
View File
@@ -7,6 +7,7 @@ import { openUrl } from "@tauri-apps/plugin-opener";
import "@xterm/xterm/css/xterm.css";
import { useTerminal } from "../../hooks/useTerminal";
import { useAppState } from "../../store/appState";
import { CLAUDE_SOFT_NEWLINE } from "../../lib/claudeInput";
import {
awsSsoRefresh,
openPageInContainerBrowser,
@@ -415,7 +416,7 @@ export default function TerminalView({ sessionId, active }: Props) {
!event.isComposing &&
sessionTypeRef.current === "claude"
) {
sendInput(sessionId, "\x1b\r");
sendInput(sessionId, CLAUDE_SOFT_NEWLINE);
// **`preventDefault()` is what stops the submit, not the `return false`.**
//
// xterm's `_keyDown` returns the instant a custom handler says `false`
+290 -10
View File
@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import { useNotes } from "./useNotes";
import { useAppState } from "../store/appState";
import type { Note } from "../lib/types";
const listNotes = vi.fn();
@@ -13,14 +14,6 @@ vi.mock("../lib/tauri-commands", () => ({
deleteNote: (p: string, id: string) => deleteNote(p, id),
}));
const pushToast = vi.fn();
vi.mock("../store/appState", () => ({
useAppState: Object.assign(
(selector: (s: unknown) => unknown) => selector({ pushToast }),
{ getState: () => ({ pushToast }) },
),
}));
const note = (over: Partial<Note> = {}): Note => ({
id: "n1",
title: "Deploy",
@@ -31,8 +24,35 @@ const note = (over: Partial<Note> = {}): Note => ({
...over,
});
/** The toasts the hook pushed. The store is real, so this is what a user sees. */
const toasts = () => useAppState.getState().toasts;
/**
* A stand-in for the Rust store: one list per project, upsert and delete
* applied to it, `list_notes` reading it back. Several of these tests are about
* what the *list* looks like after a sequence of writes, which a per-call
* `mockResolvedValueOnce` cannot express.
*/
function fakeBackend(initial: Record<string, Note[]> = {}) {
const files: Record<string, Note[]> = { ...initial };
listNotes.mockImplementation(async (p: string) => [...(files[p] ?? [])]);
saveNote.mockImplementation(async (p: string, n: Note) => {
const list = files[p] ?? (files[p] = []);
const at = list.findIndex((x) => x.id === n.id);
if (at === -1) list.unshift(n);
else list[at] = n;
return n;
});
deleteNote.mockImplementation(async (p: string, id: string) => {
files[p] = (files[p] ?? []).filter((x) => x.id !== id);
});
return files;
}
beforeEach(() => {
vi.clearAllMocks();
// The cache is shared app state now, so it has to be reset like any other.
useAppState.setState({ notesByProject: {}, notesLoading: {}, toasts: [] });
listNotes.mockResolvedValue([note()]);
saveNote.mockImplementation(async (_p: string, n: Note) => n);
deleteNote.mockResolvedValue(undefined);
@@ -60,7 +80,7 @@ describe("useNotes", () => {
expect(ok).toBe(false);
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 () => {
@@ -128,7 +148,7 @@ describe("useNotes", () => {
await waitFor(() => expect(result.current.loading).toBe(false));
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 () => {
@@ -163,4 +183,264 @@ describe("useNotes", () => {
// listNotes should be called again after the save
expect(listNotes).toHaveBeenCalledTimes(callCountBefore + 1);
});
it("does not overwrite the new project's notes when a stale save resolves", async () => {
const { result, rerender } = renderHook(
({ projectId }: { projectId: string }) => useNotes(projectId),
{ initialProps: { projectId: "p1" } },
);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.notes[0].id).toBe("n1");
// Start a save for p1 that hangs
let resolveSave: ((note: Note) => void) | undefined;
saveNote.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveSave = resolve;
}),
);
let savePromise: Promise<boolean> | undefined;
await act(async () => {
savePromise = result.current.saveNote(note({ id: "n1" }));
});
// Switch to p2 while the save is in flight
listNotes.mockResolvedValueOnce([note({ id: "n2", title: "Project 2 Note" })]);
rerender({ projectId: "p2" });
await waitFor(() => expect(result.current.loading).toBe(false));
// Now p2's note should be displayed
expect(result.current.notes).toHaveLength(1);
expect(result.current.notes[0].id).toBe("n2");
// Resolve the stale p1 save
listNotes.mockResolvedValueOnce([note({ id: "n1", body: "edited" })]);
await act(async () => {
resolveSave?.(note({ id: "n1", body: "edited" }));
await savePromise;
});
// p2's note should still be displayed, not p1's
expect(result.current.notes).toHaveLength(1);
expect(result.current.notes[0].id).toBe("n2");
});
it("keeps the notes already on screen when a refresh fails", async () => {
// The second surface mounting for a project is a refresh behind a list the
// user is already reading. One shared cache means a failed refresh would
// otherwise blank both panels.
fakeBackend({ p1: [note()] });
const tab = renderHook(() => useNotes("p1"));
await waitFor(() => expect(tab.result.current.loading).toBe(false));
listNotes.mockRejectedValueOnce(new Error("read failed"));
const dock = renderHook(() => useNotes("p1"));
await waitFor(() => expect(toasts()).toHaveLength(1));
expect(tab.result.current.notes).toHaveLength(1);
expect(dock.result.current.notes).toHaveLength(1);
});
it("does not report the old project's save on the new project's indicator", async () => {
// The indicator is per-panel and reads "Saved ✓". Firing it after a switch
// tells the user their *current* project was written when it was not.
const { result, rerender } = renderHook(
({ projectId }: { projectId: string }) => useNotes(projectId),
{ initialProps: { projectId: "p1" } },
);
await waitFor(() => expect(result.current.loading).toBe(false));
let resolveSave: ((n: Note) => void) | undefined;
saveNote.mockImplementationOnce(
() => new Promise((resolve) => (resolveSave = resolve)),
);
let savePromise: Promise<boolean> | undefined;
await act(async () => {
savePromise = result.current.saveNote(note({ body: "edited" }));
});
rerender({ projectId: "p2" });
await waitFor(() => expect(result.current.loading).toBe(false));
await act(async () => {
resolveSave?.(note({ body: "edited" }));
await savePromise;
});
expect(result.current.saveState.status).toBe("idle");
});
it("still reports a save on the indicator of the project it was made for", async () => {
const { result } = renderHook(() => useNotes("p1"));
await waitFor(() => expect(result.current.loading).toBe(false));
await act(async () => {
await result.current.saveNote(note({ body: "edited" }));
});
expect(result.current.saveState.status).toBe("saved");
});
it("serialises a project's writes so an edit cannot be re-inserted after its delete", async () => {
// Clicking Delete while the textarea has focus fires blur first, so a save
// and a delete go out back to back. The Rust write lock stops them
// interleaving but does not order them: a delete that wins the lock is
// undone by the upsert behind it, and the note comes back on next load.
const files = fakeBackend({ p1: [note()] });
const { result } = renderHook(() => useNotes("p1"));
await waitFor(() => expect(result.current.loading).toBe(false));
const order: string[] = [];
saveNote.mockImplementationOnce(async (p: string, n: Note) => {
await new Promise((r) => setTimeout(r, 20));
order.push("save");
files[p] = [n];
return n;
});
deleteNote.mockImplementationOnce(async (p: string, id: string) => {
order.push("delete");
files[p] = (files[p] ?? []).filter((x) => x.id !== id);
});
await act(async () => {
const save = result.current.saveNote(note({ body: "typo fixed" }));
const del = result.current.deleteNote("n1");
await Promise.all([save, del]);
});
expect(order).toEqual(["save", "delete"]);
expect(files.p1).toHaveLength(0);
expect(result.current.notes).toHaveLength(0);
});
it("keeps a new note when another note is saved right after it", async () => {
// A purely local draft used to be wiped by the next re-read: two clicks of
// "New note", type in the second, blur, and the first row was gone.
const files = fakeBackend({ p1: [note()] });
const { result } = renderHook(() => useNotes("p1"));
await waitFor(() => expect(result.current.loading).toBe(false));
let first: Note | null = null;
await act(async () => {
first = await result.current.createNote();
await result.current.createNote();
});
expect(result.current.notes).toHaveLength(3);
await act(async () => {
await result.current.saveNote(note({ body: "edited" }));
});
expect(result.current.notes).toHaveLength(3);
expect(result.current.notes.some((n) => n.id === first!.id)).toBe(true);
expect(files.p1).toHaveLength(3);
});
it("shares one cache between every hook watching the same project", async () => {
// The Project Home sub-tab and the dock both mount a panel for the same
// project. Two caches meant an edit in one was invisible to the other, and
// the other's next blur wrote its stale copy back over it.
fakeBackend({ p1: [note()] });
const tab = renderHook(() => useNotes("p1"));
const dock = renderHook(() => useNotes("p1"));
await waitFor(() => expect(tab.result.current.loading).toBe(false));
await waitFor(() => expect(dock.result.current.loading).toBe(false));
// One read for both — the in-flight flag is per project, not per hook.
expect(listNotes).toHaveBeenCalledTimes(1);
await act(async () => {
await dock.result.current.saveNote(note({ body: "written in the dock" }));
});
expect(tab.result.current.notes[0].body).toBe("written in the dock");
expect(tab.result.current.notes).toBe(dock.result.current.notes);
});
it("does not blank an already-loaded list when a second panel mounts", async () => {
fakeBackend({ p1: [note()] });
const tab = renderHook(() => useNotes("p1"));
await waitFor(() => expect(tab.result.current.loading).toBe(false));
const dock = renderHook(() => useNotes("p1"));
// No "Loading notes…" flash on the second surface.
expect(dock.result.current.loading).toBe(false);
expect(dock.result.current.notes).toHaveLength(1);
});
it("does not let a slow mount read overwrite a fresher post-save refresh", async () => {
// One gesture, two requests. The tab is already loaded; the user clicks the
// dock toggle with the textarea focused, so `blur` → `saveNote` and the
// dock's mount → `list_notes` are issued in the same tick. The save's
// re-read writes the post-save list; the mount's read — issued earlier,
// still in flight — must not then land its pre-save snapshot on top of it.
const files = fakeBackend({ p1: [note({ body: "before" })] });
const tab = renderHook(() => useNotes("p1"));
await waitFor(() => expect(tab.result.current.loading).toBe(false));
expect(tab.result.current.notes[0].body).toBe("before");
// The dock's mount read: it snapshots the list as it is *now* (pre-save)
// and hangs, standing in for a plain read that is slower than
// `save_note`'s double-fsync write plus the re-read behind it.
let releaseMountRead: (() => void) | undefined;
listNotes.mockImplementationOnce(async (p: string) => {
const preSave = [...(files[p] ?? [])];
await new Promise<void>((resolve) => {
releaseMountRead = resolve;
});
return preSave;
});
const dock = renderHook(() => useNotes("p1"));
expect(releaseMountRead).toBeDefined();
// The save and its re-read complete while that read is still out.
await act(async () => {
await tab.result.current.saveNote(note({ body: "after" }));
});
expect(tab.result.current.notes[0].body).toBe("after");
// Now the stale read lands.
await act(async () => {
releaseMountRead!();
await Promise.resolve();
});
expect(tab.result.current.notes[0].body).toBe("after");
expect(dock.result.current.notes[0].body).toBe("after");
});
it("does not let a slow mount read resurrect a note deleted while it was in flight", async () => {
// The other half of the same ordering rule: a confirmed delete is newer
// than any read issued before it finished, so the read's pre-delete list
// must not be written back over the shortened one.
const files = fakeBackend({ p1: [note()] });
const tab = renderHook(() => useNotes("p1"));
await waitFor(() => expect(tab.result.current.loading).toBe(false));
let releaseMountRead: (() => void) | undefined;
listNotes.mockImplementationOnce(async (p: string) => {
const preDelete = [...(files[p] ?? [])];
await new Promise<void>((resolve) => {
releaseMountRead = resolve;
});
return preDelete;
});
const dock = renderHook(() => useNotes("p1"));
expect(releaseMountRead).toBeDefined();
await act(async () => {
await tab.result.current.deleteNote("n1");
});
expect(tab.result.current.notes).toHaveLength(0);
await act(async () => {
releaseMountRead!();
await Promise.resolve();
});
expect(tab.result.current.notes).toHaveLength(0);
expect(dock.result.current.notes).toHaveLength(0);
});
});
+283 -73
View File
@@ -8,8 +8,8 @@ import { useAppState } from "../store/appState";
function draft(): Note {
const now = new Date().toISOString();
return {
// The backend owns the real id; this one only has to be unique enough to
// key the list until the first save returns.
// The backend keeps whatever id it is handed for a note it has not seen,
// so this one is the note's real id from the first save onward.
id: crypto.randomUUID(),
title: "",
body: "",
@@ -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.
*
* The backend is the source of truth and this is a cache — every mutation goes
* through a command and the returned record replaces the local one, so the
* list can never drift from the file. `saveState` mirrors `useProjectSave` so
* `ui/SaveIndicator` can report the outcome: a save that fails silently is a
* user staring at text they believe is stored.
* The backend is the source of truth and the zustand slice is the cache —
* every mutation goes through a command and the returned list replaces the
* cached one, so the list can never drift from the file. The cache lives in
* the store rather than in this hook because two surfaces show the same
* project's notes at once; see `notesByProject`.
*
* `saveState` is deliberately *not* shared: it is this panel's report of this
* panel's write, and `ui/SaveIndicator` is per-panel. A save that fails
* silently is a user staring at text they believe is stored.
*/
export function useNotes(projectId: string) {
const [notes, setNotes] = useState<Note[]>([]);
const [loading, setLoading] = useState(true);
const [saveState, setSaveState] = useState<SaveState>({ status: "idle", error: null });
const cached = useAppState((s) => s.notesByProject[projectId]);
const pushToast = useAppState((s) => s.pushToast);
const [saveState, setSaveState] = useState<SaveState>({ status: "idle", error: null });
const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const currentProjectId = useRef(projectId);
currentProjectId.current = projectId;
useEffect(() => {
if (!projectId) {
setNotes([]);
setLoading(false);
return;
}
let cancelled = false;
setLoading(true);
setNotes([]);
if (!projectId) return;
// Read through `getState` rather than through subscribed values: the
// effect must fire once per project, not again every time the flag it sets
// changes. Two panels mounting for the same project therefore make one
// read, and the second renders from the cache with no loading flash.
const store = useAppState.getState();
if (store.notesLoading[projectId]) return;
store.setNotesLoading(projectId, true);
const seq = issueNotesWrite(projectId);
commands
.listNotes(projectId)
.then((loaded) => {
if (!cancelled) setNotes(loaded);
commitNotes(projectId, seq, loaded);
})
.catch((e) => {
if (cancelled) return;
setNotes([]);
// A project that has never been read caches the empty list, so a panel
// does not sit on "Loading notes…" forever. One that *has* been read
// keeps what it has: this load is a refresh behind a list already on
// screen — the second surface mounting, say — and a failed refresh
// must not blank both of them. Same rule as `refresh()`. Neither
// branch has a stale-project hazard, because the write is keyed by the
// project it belongs to.
//
// The commit goes under this read's own sequence, not a fresh one: a
// *later* read still in flight has the newer answer and must not be
// dropped in favour of this failure's empty list.
if (useAppState.getState().notesByProject[projectId] === undefined) {
commitNotes(projectId, seq, []);
}
pushToast({
kind: "error",
message: "Could not load notes for this project",
@@ -59,11 +209,8 @@ export function useNotes(projectId: string) {
});
})
.finally(() => {
if (!cancelled) setLoading(false);
useAppState.getState().setNotesLoading(projectId, false);
});
return () => {
cancelled = true;
};
}, [projectId, pushToast]);
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(() => {
setSaveState({ status: "saved", error: null });
if (resetTimer.current) clearTimeout(resetTimer.current);
@@ -83,58 +251,100 @@ export function useNotes(projectId: string) {
}, []);
const saveNote = useCallback(
async (note: Note) => {
if (resetTimer.current) clearTimeout(resetTimer.current);
setSaveState({ status: "saving", error: null });
try {
await commands.saveNote(projectId, note);
// Re-read the canonical list from the backend. A successful save stamps a new
// `updated_at`, and the backend sorts unpinned notes by `updated_at` descending,
// so the record's position has changed and positional patching would disagree with
// what a reload would show.
try {
const reloaded = await commands.listNotes(projectId);
setNotes(reloaded);
} catch {
// Keep the save reported as successful (it was) and leave the existing list alone
// rather than clearing it if the re-read fails.
(note: Note) =>
enqueueMutation(projectId, async () => {
if (isCurrent()) {
if (resetTimer.current) clearTimeout(resetTimer.current);
setSaveState({ status: "saving", error: null });
}
succeeded();
return true;
} catch (e) {
const message = String(e);
setSaveState({ status: "failed", error: message });
pushToast({ kind: "error", message: "Could not save note", detail: message });
return false;
}
},
[projectId, pushToast, succeeded],
try {
await commands.saveNote(projectId, note);
await refresh(projectId);
if (isCurrent()) succeeded();
return true;
} catch (e) {
const message = String(e);
if (isCurrent()) setSaveState({ status: "failed", error: message });
// The toast is not project-scoped — it names the failure and stays
// readable after a switch — so it fires either way.
pushToast({ kind: "error", message: "Could not save note", detail: message });
return false;
}
}),
[projectId, pushToast, succeeded, isCurrent],
);
const createNote = useCallback(async () => {
const note = draft();
// Held locally first so the editor can focus it immediately; the save
// happens on blur like every other edit. The note does not exist backend-side,
// so no re-read can place it and no backend ordering applies to it yet. Prepending
// puts it at the top where the user can see it immediately, and on the first save
// its canonical position is established.
setNotes((current) => [note, ...current]);
return note;
}, []);
const deleteNote = useCallback(
async (noteId: string) => {
try {
await commands.deleteNote(projectId, noteId);
setNotes((current) => current.filter((n) => n.id !== noteId));
return true;
} catch (e) {
pushToast({ kind: "error", message: "Could not delete note", detail: String(e) });
return false;
}
},
/**
* Create a note by persisting it, rather than holding it locally until the
* first blur.
*
* The draft used to live only in the list, which meant any *other* note
* being saved replaced the list with the backend's and the unsaved draft
* silently vanished — click "New note" twice, type in the second, blur, and
* the first row is gone. Sharing one cache between two surfaces makes that
* worse rather than better: a local-only row would exist in whichever panel
* created it and nowhere else. Letting the backend own the row from the
* start removes the whole class: there is no such thing as a note in the
* list that the file does not have.
*/
const createNote = useCallback(
() =>
enqueueMutation(projectId, async () => {
const note = draft();
try {
const saved = await commands.saveNote(projectId, note);
if (!(await refresh(projectId))) {
// The note exists; only the re-read failed. Show it rather than
// leaving the user with a button that did nothing visible.
const store = useAppState.getState();
commitNotes(projectId, issueNotesWrite(projectId), [
saved,
...(store.notesByProject[projectId] ?? []),
]);
}
return saved;
} catch (e) {
pushToast({
kind: "error",
message: "Could not create note",
detail: String(e),
});
return null;
}
}),
[projectId, pushToast],
);
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,
};
}
+44
View File
@@ -0,0 +1,44 @@
import { describe, it, expect } from "vitest";
import { CLAUDE_SOFT_NEWLINE, toClaudePayload } from "./claudeInput";
describe("toClaudePayload", () => {
it("is ESC+CR, the sequence Claude Code's own /terminal-setup installs", () => {
expect(CLAUDE_SOFT_NEWLINE).toBe("\x1b\r");
});
it("replaces every newline so the note arrives as one prompt", () => {
// Typed raw, each \n submits — the note would arrive as three truncated
// messages instead of one.
expect(toClaudePayload("one\ntwo\nthree")).toBe("one\x1b\rtwo\x1b\rthree");
});
it("normalises CRLF, which is what a paste from Windows carries", () => {
expect(toClaudePayload("one\r\ntwo")).toBe("one\x1b\rtwo");
});
it("normalises a lone CR, which would otherwise submit", () => {
// A bare \r is a carriage return: it submits in a Claude prompt and runs
// the line in a shell — the terminator this function promises not to
// append. A textarea cannot make one, but a notes file that was
// hand-edited or written by something else can, and `load_in` hands it
// straight back.
expect(toClaudePayload("one\rtwo")).toBe("one\x1b\rtwo");
expect(toClaudePayload("one\rtwo\r\nthree\nfour")).toBe(
"one\x1b\rtwo\x1b\rthree\x1b\rfour",
);
expect(toClaudePayload("text\r").endsWith("\r")).toBe(true);
// …but only as the tail of the soft-newline sequence, never bare.
expect(toClaudePayload("text\r")).toBe("text\x1b\r");
});
it("leaves single-line text untouched", () => {
expect(toClaudePayload("just one line")).toBe("just one line");
});
it("never appends a terminator", () => {
// The note lands in the prompt unsubmitted; the user presses Enter. An
// unsent prompt is recoverable, a sent one is not.
expect(toClaudePayload("text").endsWith("\r")).toBe(false);
expect(toClaudePayload("text\n")).toBe("text\x1b\r");
});
});
+36
View File
@@ -0,0 +1,36 @@
/**
* The bytes that insert a newline in Claude Code's prompt without submitting
* it: ESC then CR.
*
* These are the in-band bytes, not a guess — they are exactly what Claude
* Code's own `/terminal-setup` writes into the VS Code, Cursor, Alacritty and
* Zed keymaps, and `TerminalView`'s Shift+Enter handler has sent them since
* that feature landed. **This must not be "simplified" to `\n`:** Claude Code
* accepts `\n` too, but a shell would *run* the line, so the two session types
* would quietly diverge.
*
* That last sentence is also why anything sending this must first check the
* session is a Claude one. `bash -l`'s readline has no binding for `\e\r` and
* answers with a bell.
*/
export const CLAUDE_SOFT_NEWLINE = "\x1b\r";
/**
* Turn multi-line text into something that arrives in a Claude prompt as one
* message.
*
* Sent as raw keystrokes, every `\n` submits, so an N-line note would arrive
* as N truncated prompts. Deliberately appends no terminator: the text lands
* in the prompt and the user presses Enter, which is what speech-to-text does
* for the same reason — an unsent prompt is recoverable and a sent one is not.
*
* A **lone** `\r` is matched too, not only the one in a CRLF. It is a carriage
* return: it submits in a Claude prompt and runs the line in a shell, which is
* exactly the terminator this function promises never to append. A `<textarea>`
* cannot produce one, but a note body is read back from a JSON file that can be
* hand-edited or written by something else, so the guarantee has to hold for
* whatever `load_in` returns rather than for whatever the editor can type.
*/
export function toClaudePayload(text: string): string {
return text.replace(/\r\n|\r|\n/g, CLAUDE_SOFT_NEWLINE);
}
+45
View File
@@ -0,0 +1,45 @@
import { describe, it, expect } from "vitest";
import { sessionDisplayName } from "./sessionName";
import type { Project, TerminalSession } from "./types";
const session = (over: Partial<TerminalSession> = {}): TerminalSession => ({
id: "s1",
projectId: "p1",
projectName: "api",
sessionType: "claude",
sessionName: null,
...over,
});
const project = (renamed: Record<string, string> = {}) =>
({ id: "p1", name: "api", renamed_session_names: renamed }) as unknown as Project;
describe("sessionDisplayName", () => {
it("prefers a user-set custom name, prefixed with the project", () => {
expect(sessionDisplayName(session(), project({ s1: "release work" }))).toBe(
"api: release work",
);
});
it("falls back to the session name when there is no custom one", () => {
expect(sessionDisplayName(session({ sessionName: "review" }), project())).toBe("review");
});
it("falls back to the project name when there is no session name", () => {
expect(sessionDisplayName(session(), project())).toBe("api");
});
it("marks bash sessions", () => {
expect(sessionDisplayName(session({ sessionType: "bash" }), project())).toBe("api (bash)");
});
it("works with no project, which is how a closing tab renders", () => {
expect(sessionDisplayName(session())).toBe("api");
});
it("does not mark bash when a custom name is set, matching the existing rule", () => {
expect(
sessionDisplayName(session({ sessionType: "bash" }), project({ s1: "logs" })),
).toBe("api: logs");
});
});
+27
View File
@@ -0,0 +1,27 @@
import type { Project, TerminalSession } from "./types";
/**
* What a terminal session is called on screen.
*
* The rule used to be written twice inside `MainTabs.tsx` — once in `tabLabel`
* for the drag ghost, once inline in `renderTab` — both local and neither
* exported, so the two could disagree the moment either was edited. It is here
* because a third caller (the note send-target picker) would have made that
* three.
*
* A user-set name wins and is prefixed with the project, because a custom name
* is usually about the work rather than the project and needs the context. The
* `(bash)` marker only appears on the fallback: a session someone bothered to
* name does not need to be told apart from its neighbours.
*/
export function sessionDisplayName(
session: TerminalSession,
project?: Project,
): string {
const custom = project?.renamed_session_names?.[session.id];
if (custom) return `${session.projectName}: ${custom}`;
return (
(session.sessionName ?? session.projectName) +
(session.sessionType === "bash" ? " (bash)" : "")
);
}
+119 -1
View File
@@ -1,5 +1,12 @@
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";
@@ -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
* 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. */
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.
containerProgress: Record<string, string>;
setContainerProgress: (projectId: string, message: string | null) => void;
@@ -127,6 +205,13 @@ interface AppState {
sidebarCollapsed: boolean;
setSidebarCollapsed: (collapsed: boolean) => 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;
setDockerAvailable: (available: boolean | null) => void;
imageExists: boolean | null;
@@ -340,6 +425,22 @@ export const useAppState = create<AppState>((set) => ({
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
containerProgress: {},
setContainerProgress: (projectId, message) =>
@@ -396,6 +497,23 @@ export const useAppState = create<AppState>((set) => ({
persistSidebarCollapsed(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,
setDockerAvailable: (available) => set({ dockerAvailable: available }),
imageExists: null,
+62
View File
@@ -0,0 +1,62 @@
import { describe, it, expect, afterEach, vi } from "vitest";
import {
clampDockWidth,
NOTES_DOCK_MIN_WIDTH,
NOTES_DOCK_MAX_WIDTH,
NOTES_DOCK_DEFAULT_WIDTH,
} from "./appState";
describe("clampDockWidth", () => {
it("keeps a sensible width", () => {
expect(clampDockWidth(400)).toBe(400);
});
it("refuses to squeeze the dock into uselessness", () => {
expect(clampDockWidth(10)).toBe(NOTES_DOCK_MIN_WIDTH);
});
it("refuses to squeeze the terminal into uselessness", () => {
expect(clampDockWidth(5000)).toBe(NOTES_DOCK_MAX_WIDTH);
});
it("falls back for a stored value that is not a number", () => {
// localStorage holds strings and can carry anything a previous version,
// a hand edit, or a different screen left behind.
expect(clampDockWidth(Number("banana"))).toBe(NOTES_DOCK_DEFAULT_WIDTH);
});
it("rounds, because a fractional px width blurs the border", () => {
expect(clampDockWidth(400.6)).toBe(401);
});
});
// The pure function above is only half the contract: the brief calls out that
// the clamp must guard the *read* path too, because localStorage can carry
// anything a previous version, a hand edit, or a different screen left
// behind. These tests exercise the real store initialization — seeding
// localStorage, then re-importing the module fresh so its top-level
// `loadNotesDockWidth()` call runs against the seeded value — rather than a
// function pulled out just to make this testable. A future refactor that
// dropped the clamp from the load path while keeping it on the write path
// would fail these.
describe("notesDockWidth store initialization", () => {
const WIDTH_KEY = "triple-c.notes.dock.width";
afterEach(() => {
localStorage.removeItem(WIDTH_KEY);
});
it("clamps an out-of-range stored value on load", async () => {
localStorage.setItem(WIDTH_KEY, "99999");
vi.resetModules();
const { useAppState } = await import("./appState");
expect(useAppState.getState().notesDockWidth).toBe(NOTES_DOCK_MAX_WIDTH);
});
it("falls back to the default for a non-numeric stored value on load", async () => {
localStorage.setItem(WIDTH_KEY, "banana");
vi.resetModules();
const { useAppState } = await import("./appState");
expect(useAppState.getState().notesDockWidth).toBe(NOTES_DOCK_DEFAULT_WIDTH);
});
});
@@ -85,6 +85,13 @@ struct ProjectNotes { version: u32, notes: Vec<Note> }
Order is pinned-first then `updated_at` descending. Manual reordering is deliberately out.
**`pinned` is reserved, and nothing in v1 sets it.** The field is persisted and sorted on, but
there is no pin control and no pinned indicator anywhere in the UI, so in v1 every note sorts
by `updated_at` descending and the pinned-first half of the rule is inert. It is carried from
the start because it is a field in a file: adding one later means every reader has to tolerate
its absence forever, while an unused `bool` with a serde default costs nothing. Pinning itself
is out of scope — see §8.
### Why not a field on `Project`
`projects.json` is written on **every blur** by the debounced `useProjectSave` path
@@ -339,6 +346,9 @@ that — but anything touching real key handling needs a manual check in Chromiu
generic write-a-file-to-container command today (only `write_file_to_container` for image
paste and `upload_bytes_to_container` for migration), and a second storage path with a
sync direction is a v2 conversation.
- Pinning. `Note.pinned` exists on both sides of the IPC boundary and the backend sorts on
it, but no UI sets it and none indicates it — see §1. A pin control is a user-facing
affordance and belongs in the change that adds it, not in the storage that anticipates it.
- Tags, full-text search, manual reordering, note history.
- Any change to `claude_instructions`. The two features stay distinct: ambient context
versus fired-on-demand items.