Design a per-project Notes tab with a send-to-agent action
Notes are discrete, addressable items with a button that puts one into a running Claude session's prompt. That is deliberately not what `claude_instructions` does — that field is *ambient*, merged into the container's CLAUDE.md on every start and always in context. Nor is it a `NOTES.md` in the workspace, which the agent can read but the user cannot, once the container is stopped. Discrete items, fired on demand, readable with the container down, is the gap neither of those covers. Storage is one file per project under the app data dir, following `migration_store.rs` rather than living on the `Project` record: that record is rewritten on every blur by the debounced save path, so notes there would mean the whole project list is rewritten per keystroke-batch and a note edit could clobber a Config edit. `migration_store.rs` already documents that reasoning for itself. Two findings are worth more than the design they support. **Newlines already have a verified answer.** A note body has newlines; typed as raw keystrokes each one submits a separate prompt, so a note would arrive as N truncated messages. `TerminalView.tsx` already sends `\x1b\r` for Shift+Enter and its comment states those are the in-band bytes, not a guess, with an explicit warning against simplifying to `\n` because a shell would run the line. Send-to-agent reuses that sequence through one shared helper, and — from the same comment — only offers `claude` sessions as targets, since bash's readline has no binding for it and merely bells. **The dock cannot widen the OS window.** A throwaway Tauri app was built and run on KDE Plasma to find out, because the app has no window-geometry code to reason from. Under XWayland every test passed exactly. Under native Wayland the same binary asked +420 and got +600, moved the height +276 without being asked, compounded that offset on every call, and ended reporting 5400x2900 on a 4800x2700 monitor. Worse, `outer_position()` did not fail — it returned `Ok(0,0)` for a window that was not at 0,0, so a "cannot determine position, do not grow" fallback never fires. A clean failure could have been handled; a plausible wrong answer cannot be detected from the value itself. AppImages get XWayland because linuxdeploy-plugin-gtk forces GDK_BACKEND=x11; the .deb and .rpm do not. The split is therefore by *packaging*, not platform — two users on identical hardware would see different behavior. So the dock takes space inward on every backend, which also costs nothing: the ResizeObserver in `TerminalView.tsx` already reflows xterm and resizes the container PTY on width change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HjL1E2JFNctUqCYotUwqqb
This commit is contained in:
@@ -0,0 +1,323 @@
|
||||
# Project Notes — design
|
||||
|
||||
**Date:** 2026-09-01 · **Baseline:** v0.4 · Companion to [ROADMAP.md](../../../ROADMAP.md)
|
||||
and [DESIGN-REVIEW.md](../../../DESIGN-REVIEW.md).
|
||||
|
||||
A per-project notes surface, with a per-note **Send to agent** action that puts the note
|
||||
into a running Claude session's prompt.
|
||||
|
||||
---
|
||||
|
||||
## Why this earns a slot
|
||||
|
||||
DESIGN-REVIEW's coherence test says every screen answers exactly one question. Notes
|
||||
answers *"what do I want to hand this agent, and what did I keep learning here?"* — and it
|
||||
answers it **while the container is stopped**, which is the gap the stop/start container
|
||||
model creates and the same reasoning that made Sessions/Resume the flagship.
|
||||
|
||||
Two things already do part of this job, and the design is shaped to avoid both:
|
||||
|
||||
- `Project.claude_instructions` (`models/project.rs:405`, editor at
|
||||
`components/projects/ClaudeInstructionsEditor.tsx`) is per-project free text merged into
|
||||
the container's `CLAUDE.md` on every start. It is **ambient** — always in context, never
|
||||
addressed. Notes are **discrete and fired on demand**. If Notes drifts into a second
|
||||
instructions box, it is redundant with a feature that already ships.
|
||||
- A `NOTES.md` in the workspace is readable by the agent already, but unreadable by the
|
||||
user when the container is stopped, and invisible to the fleet view.
|
||||
|
||||
What Notes uniquely adds is *addressable items with a fire-at-the-session action*.
|
||||
|
||||
## Decisions taken
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|---|---|---|
|
||||
| Audience | Human scratchpad **and** agent prompts, one surface | See "no note types" below |
|
||||
| Storage | Own file per project, host-side | Keeps prose out of `projects.json`; works with the container stopped |
|
||||
| Send target | Project's own sessions; picker when >1 | Never guesses; mirrors STT's target-pinning guard |
|
||||
| Surface | Side dock that takes space **inward**; never resizes the OS window | Phase 0 spike: growing corrupts under native Wayland, §6.1 |
|
||||
| Formatting | Plain text, no markdown | It is a scratchpad; see §3 |
|
||||
| Tab position | Last, after Browser | A companion to the work, not a step in it |
|
||||
|
||||
**No note *types*.** A note is a title plus a body. What makes one "for the agent" is that
|
||||
you pressed the button, not a mode set at creation. The moment there is a "prompt note" vs
|
||||
"scratch note" toggle, the pane is two features wearing one coat, and every note costs a
|
||||
classification decision at the moment of writing — which is the moment the user is least
|
||||
willing to make one.
|
||||
|
||||
---
|
||||
|
||||
## 1. Storage
|
||||
|
||||
New `app/src-tauri/src/storage/notes_store.rs`, modeled on `migration_store.rs` rather than
|
||||
on `projects_store.rs`:
|
||||
|
||||
```
|
||||
<data_dir>/triple-c/notes/{project_id}.json
|
||||
```
|
||||
|
||||
- **`sanitize()` on the project id**, copied from `migration_store.rs:41-46`. The id arrives
|
||||
over IPC; it must not be able to steer the write.
|
||||
- **Atomic write** — `.tmp` then `rename()`, per `projects_store.rs:167-179` and the Key
|
||||
Conventions rule in CLAUDE.md.
|
||||
- **Corrupt file is quarantined to `.bak`, not discarded** (`projects_store.rs:24-61`).
|
||||
|
||||
```rust
|
||||
struct Note {
|
||||
id: String, // uuid v4
|
||||
title: String,
|
||||
body: String,
|
||||
pinned: bool,
|
||||
created_at: String, // RFC 3339
|
||||
updated_at: String,
|
||||
}
|
||||
struct ProjectNotes { version: u32, notes: Vec<Note> }
|
||||
```
|
||||
|
||||
Order is pinned-first then `updated_at` descending. Manual reordering is deliberately out.
|
||||
|
||||
### Why not a field on `Project`
|
||||
|
||||
`projects.json` is written on **every blur** by the debounced `useProjectSave` path
|
||||
(`hooks/useSaveState.ts`, threaded through `ProjectHome.tsx:79-81` into Overview and
|
||||
Config). Long user prose on that record means (a) the whole project list is rewritten every
|
||||
time a note changes, and (b) a note edit and a Config edit can race, with the loser's write
|
||||
clobbering the winner's. `migration_store.rs:1-12` already documents this exact reasoning
|
||||
for why *it* is not in `projects.json`. Notes inherit it.
|
||||
|
||||
A per-project file also means a corrupt notes file loses notes for one project, not the
|
||||
project list.
|
||||
|
||||
### Lifecycle
|
||||
|
||||
`remove_project` deletes the project's notes file. A failure there is logged, never fatal —
|
||||
an orphaned notes file is harmless, a project that cannot be removed is not.
|
||||
|
||||
## 2. Commands and frontend state
|
||||
|
||||
Registered in `lib.rs` via `generate_handler!`. Per CLAUDE.md, application commands need
|
||||
**no** entry in `capabilities/default.json`.
|
||||
|
||||
- `list_notes(projectId) -> Vec<Note>`
|
||||
- `save_note(projectId, note) -> Note` — upsert; stamps `updated_at` backend-side
|
||||
- `delete_note(projectId, noteId)`
|
||||
|
||||
There is deliberately **no whole-list setter**. Bulk writes are the clobbering mechanism the
|
||||
storage choice above exists to avoid; a read-modify-write under the store's `Mutex` per note
|
||||
is both correct and cheap.
|
||||
|
||||
Frontend: wrappers in `lib/tauri-commands.ts`, a `hooks/useNotes.ts`, and notes cached in
|
||||
zustand keyed by project id. Rust is the source of truth; the cache is a cache.
|
||||
|
||||
The dock and the tab live in one webview, so zustand alone suffices. The store boundary is
|
||||
drawn so that a future detached window (§8) only swaps the transport: Rust emits a
|
||||
`notes-changed` event, both windows listen.
|
||||
|
||||
## 3. Editor — plain text, deliberately
|
||||
|
||||
A note is a title and a `<textarea>`, with save-on-blur plus a debounce, following
|
||||
`ClaudeInstructionsEditor.tsx`. **No rich editor, no markdown library, and no markdown
|
||||
rendering** — it is a scratchpad for reminders, and it stays one.
|
||||
|
||||
The body is stored and displayed exactly as typed. There is no view/edit mode split, so
|
||||
there is no state to get wrong and no moment where the text the user is looking at is not
|
||||
the text that would be sent.
|
||||
|
||||
This also keeps `renderMarkdown()` (`components/layout/HelpDialog.tsx:55-160`) where it is.
|
||||
It was written for Help content, it entity-escapes before converting to make its
|
||||
`dangerouslySetInnerHTML` sink safe, and `HelpDialog.test.tsx` asserts that escaping as a
|
||||
security rule. Reusing it here would mean extracting it and giving a hand-rolled HTML
|
||||
converter a second caller with different content — cost and risk, for formatting a
|
||||
scratchpad. If notes later need rendering, that extraction is the change to make; it is not
|
||||
this change.
|
||||
|
||||
One consequence worth stating: the text sent to the agent is byte-for-byte what is in the
|
||||
box. Nothing is transformed on the way out except the newline substitution in §5.
|
||||
|
||||
## 4. The Notes tab
|
||||
|
||||
- One entry in the `TABS` registry (`components/projects/home/ProjectHome.tsx:24-33`), one
|
||||
line in the panel switch (`:237-257`), one new `home/NotesTab.tsx` taking the sibling prop
|
||||
shape `{ project: Project }`.
|
||||
- Order: Notes goes **last**, after Browser — **Overview / Sessions / Automation / Config /
|
||||
Files / Browser / Notes**. It is a companion to the work, not a step in it, and the
|
||||
existing order runs roughly from "what is this" to "what is in it".
|
||||
- Layout is master/detail: title list left, editor right.
|
||||
|
||||
Note that the active sub-tab is local `useState` (`ProjectHome.tsx:47`) and is not
|
||||
persisted, so a closed and reopened home tab returns to Overview. Notes inherits that; it is
|
||||
not worth changing here.
|
||||
|
||||
## 5. Send to agent
|
||||
|
||||
### The newline problem, and why it is already solved
|
||||
|
||||
A dictated STT phrase has no newlines. A note body does. Typed as raw keystrokes, every
|
||||
`\n` in a body **submits a separate prompt** — the note would arrive as N truncated
|
||||
messages.
|
||||
|
||||
The answer is in the codebase already. `components/terminal/TerminalView.tsx` (~:400-430)
|
||||
handles Shift+Enter by sending `\x1b\r`, and its comment states these are "the in-band
|
||||
bytes, not a guess," with an explicit warning **not** to simplify to `\n` because a shell
|
||||
would *run* the line. So:
|
||||
|
||||
```
|
||||
payload = note.body.replace(/\r?\n/g, "\x1b\r")
|
||||
```
|
||||
|
||||
sent with **no trailing CR** — the user presses Enter. Same rationale as STT sending
|
||||
without one: a note is longer than a dictated sentence, so the chance of wanting an edit
|
||||
before firing is higher, and an unsent prompt is recoverable while a sent one is not.
|
||||
|
||||
Two consequences follow from that same comment:
|
||||
|
||||
1. **Only `sessionType === "claude"` sessions are offered as targets.** `bash -l`'s readline
|
||||
has no binding for `\e\r` and answers with a bell. Bash tabs are not listed in the picker
|
||||
at all.
|
||||
2. **The sequence lives in one shared helper**, not a second `"\x1b\r"` literal. The
|
||||
knowledge in that comment is hard-won and must not be duplicated away from it.
|
||||
|
||||
### Target resolution
|
||||
|
||||
`TerminalSession` (`lib/types.ts:228-234`) already carries `projectId`, `projectName`,
|
||||
`sessionType` and `sessionName`, so no new plumbing is needed.
|
||||
|
||||
| Claude sessions for this project | Behavior |
|
||||
|---|---|
|
||||
| 0 | Button disabled, "no running session for this project" |
|
||||
| 1 | Send |
|
||||
| >1 | Menu of session display names (`Project.renamed_session_names` where set) |
|
||||
|
||||
- **The target is pinned at click time**, per the hazard `useSTT.ts:20,30` guards against
|
||||
(it pins at record-start so text does not land in whatever tab is active at stop time).
|
||||
- Transport is `useTerminal`'s module-scoped ordered queue (`hooks/useTerminal.ts:32-85`,
|
||||
exposed as `sendInput` at `:135-141`) → `terminal_input` → `exec_manager.send_input`. That
|
||||
queue exists because parallel `invoke`s raced the session mutex and reordered keystrokes
|
||||
(`useTerminal.ts:7-31`); a multi-line note is exactly the payload that would expose it.
|
||||
- After sending, switch the active tab to that terminal so the user watches it land. This is
|
||||
a courtesy, not a correctness requirement: if it cannot be delivered, the send still
|
||||
succeeded.
|
||||
- **Body only, not the title.** The title is an index label for the list, not content.
|
||||
|
||||
Explicitly *not* reused: `useProjectActions.ts:104-124`'s `openTerminalWithCommand`, which
|
||||
opens a shell then types after a `setTimeout(700)`. Starting a container as a side effect of
|
||||
clicking a note is too large an implicit action, and that timing hack should not spread.
|
||||
|
||||
## 6. Surface — a dock that takes space inward
|
||||
|
||||
`components/layout/NotesDock.tsx`, a flex sibling of the tab panels in `App.tsx:139-160`, so
|
||||
it is visible over **any** top-level tab including Terminal. This is the point of the dock:
|
||||
Project Home and Terminal are sibling top-level tabs (`layout/MainTabs.tsx`), so a
|
||||
Notes-only-as-sub-tab design hides notes exactly when the agent is running.
|
||||
|
||||
Opening the dock **takes space from inside the window**. The terminal narrows and reflows;
|
||||
the OS window is never resized or moved. `TerminalView.tsx:643-656` already has a
|
||||
rAF-throttled `ResizeObserver` that calls `fitAddon.fit()` then `resize(sessionId, cols,
|
||||
rows)` → `terminal_resize`, so narrowing reflows xterm *and* resizes the container PTY
|
||||
correctly, with no new code.
|
||||
|
||||
- Width is drag-resizable, persisted to a `triple-c.notes.dock` localStorage key. Precedent:
|
||||
`triple-c.sidebar.collapsed` (`store/appState.ts:4-20`) is the app's only such key today.
|
||||
- **The dock follows the active tab's project** — terminal tab → that session's project,
|
||||
home tab → that project, nothing active → empty state. `activeTabKey`/`tabKeyId` plus
|
||||
`TerminalSession.projectId` already provide this.
|
||||
- **No window geometry code at all.** No `set_size`, no `set_position`, no monitor work-area
|
||||
arithmetic, no platform checks. §6.1 is why.
|
||||
|
||||
### 6.1 Why the dock does not widen the window — Phase 0 spike
|
||||
|
||||
The original design had the dock "expand outward" by widening the OS window, so the terminal
|
||||
kept its size. A throwaway Tauri app (`geo-spike`) was built and run on the target desktop —
|
||||
KDE Plasma, Wayland session, 2026-09-01 — because the app contains no window-geometry code
|
||||
today and the behavior could not be predicted. It was run twice, once per GDK backend,
|
||||
which turned out to matter more than the platform.
|
||||
|
||||
**Under XWayland** (what every Tauri AppImage gets, because `linuxdeploy-plugin-gtk` exports
|
||||
`GDK_BACKEND=x11` in `AppRun`, citing
|
||||
[tauri-apps/tauri#8541](https://github.com/tauri-apps/tauri/issues/8541)) everything worked:
|
||||
|
||||
| Test | Result |
|
||||
|---|---|
|
||||
| Grow while floating | asked +420, got +420 — exact |
|
||||
| Shrink back | asked -420, got -420 — exact |
|
||||
| `outer_position()` | readable, correct |
|
||||
| `work_area` | 3840x2099 — correctly excludes the 61px Plasma panel |
|
||||
| Grow while maximized / fullscreen | ignored, as designed |
|
||||
|
||||
**Under native Wayland** (what the `.deb` and `.rpm` builds get, since they carry no such
|
||||
hook) the same binary failed — and failed *silently*, which is the part that decided this:
|
||||
|
||||
| Test | Result |
|
||||
|---|---|
|
||||
| Grow while floating | asked +420, got **+600**; height moved **+276 unrequested** |
|
||||
| Shrink back | asked -420, got **-240**; height **+276** again |
|
||||
| After unmaximize | window reports **5400x2900 on a 4800x2700 monitor** |
|
||||
| `outer_position()` | returned `Ok(0,0)` — for a window that was not at 0,0 |
|
||||
| Grow while maximized / fullscreen | ignored, as designed |
|
||||
| `set_position` | ignored, as expected |
|
||||
|
||||
Two independent failures, either one sufficient:
|
||||
|
||||
1. **Resize compounds.** Under Wayland GTK owns the frame and shadows; both `outer` and
|
||||
`inner` report a 0x0 decoration, so every read-back is inflated by a fixed offset and
|
||||
every write built on a read-back compounds it. There is no size that can be read and
|
||||
safely written back. Three calls in, the window is larger than the display.
|
||||
2. **Position is a confident lie, not an honest failure.** `outer_position()` returned
|
||||
`Ok(0,0)` rather than an error. A design that treats "cannot determine position" as
|
||||
"do not grow" never triggers, because the value looks perfectly valid. The room check
|
||||
duly reported `slack: 2820px, VERDICT: Grow` from a false origin.
|
||||
|
||||
The second point is what rules out a runtime fallback. A clean failure could have been
|
||||
handled; a plausible wrong answer cannot be detected from the value itself.
|
||||
|
||||
Growing therefore works on one packaging channel and corrupts on another — the split is by
|
||||
**packaging, not platform**, which is worse than a platform split because two users on
|
||||
identical hardware and OS would see different behavior. A dock that takes space inward
|
||||
behaves identically on every backend, OS and package, needs no detection, and reuses a
|
||||
resize path that is already exercised by every terminal in the app.
|
||||
|
||||
**Kept as evidence, not as guidance:** `set_position` was honoured under XWayland. The design
|
||||
does not move the window and must not start.
|
||||
|
||||
## 7. Testing
|
||||
|
||||
Vitest + jsdom + React Testing Library for the frontend, `#[cfg(test)]` for Rust, per
|
||||
CLAUDE.md's Testing section.
|
||||
|
||||
**Rust (`notes_store.rs`)**
|
||||
- `sanitize()` rejects traversal and separator characters in a project id
|
||||
- atomic write leaves no `.tmp` behind; a crash mid-write leaves the previous file intact
|
||||
- a corrupt file is moved to `.bak` and the store opens empty rather than erroring
|
||||
- removing a project deletes its notes file; a delete failure does not fail removal
|
||||
|
||||
**Frontend**
|
||||
- send-target resolution at 0 / 1 / N claude sessions, and that bash sessions are excluded
|
||||
- the newline transform: a multi-line body becomes `\x1b\r`-joined, with no trailing CR
|
||||
- the dock's project resolution: terminal tab, home tab, and nothing active
|
||||
- save-on-blur persists, and dock width round-trips through localStorage
|
||||
|
||||
Note the limit `TerminalView.tsx`'s own comment records: jsdom never synthesizes the
|
||||
follow-up keypress, so keyboard-path bugs of that family are invisible to unit tests. The
|
||||
send path is a direct `sendInput` call rather than a synthetic keystroke, which sidesteps
|
||||
that — but anything touching real key handling needs a manual check in Chromium.
|
||||
|
||||
## 8. Out of scope for v1
|
||||
|
||||
- A detached second window for a second monitor. The store boundary in §2 is drawn so it is
|
||||
an additive follow-up: emit `notes-changed` from the store's write path, and add a second
|
||||
narrowly scoped capability granting the notes window `core:event:allow-listen` /
|
||||
`allow-unlisten` — `capabilities/default.json` scopes those to `"windows": ["main"]` today,
|
||||
and cross-window sync needs them. Application commands need no ACL entry (CLAUDE.md, Key
|
||||
Conventions), so only the events require it. `lib.rs:379-387`'s main-window-only close
|
||||
handler would need review at that point.
|
||||
- Syncing notes into the workspace as `.md` for the agent to read unprompted. There is no
|
||||
generic write-a-file-to-container command today (only `write_file_to_container` for image
|
||||
paste and `upload_bytes_to_container` for migration), and a second storage path with a
|
||||
sync direction is a v2 conversation.
|
||||
- Tags, full-text search, manual reordering, note history.
|
||||
- Any change to `claude_instructions`. The two features stay distinct: ambient context
|
||||
versus fired-on-demand items.
|
||||
|
||||
## 9. Open questions
|
||||
|
||||
None. The Phase 0 spike settled the surface (§6.1); every other decision is recorded in the
|
||||
table above.
|
||||
Reference in New Issue
Block a user