Skip to content

Commit 23c8b8d

Browse files
simionclaude
andcommitted
docs(plans): scratchpad tabs, approved and specced (#244)
Promotes the scratchpad from the issue thread to an implementation-ready plan. The defining rule is written down first because it is the one that can be got backwards: a scratchpad is an UNSAVED buffer that happens to survive restarts, so ⌘S promotes it to a real file inside the project rather than quietly writing to the scratch store. Two things follow that read as contradictions until you hold that rule — quitting keeps your pads while closing one asks, and a pad stays dirty for its whole life. Storage is project-scoped, not task-scoped, which is the direct answer to the objection in the thread: a note about work that outlives a task has to outlive the task. Also covers the Rust commands (promotion is one command so the worktree containment check is not re-implemented in TypeScript), the new tab type and why it is not an EditTab with an empty path, the traps this codebase already knows about (bear trap 8 on the typing path, preview-tab recycling, the e2e profile sharing data_dir), and what v1 leaves out. Issue labelled `planned` at the same time. The README roadmap needs its Planned bullet, which is maintainer-only to edit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F2JzhUsFa9YLjBBvUJQGYj
1 parent 68845a1 commit 23c8b8d

1 file changed

Lines changed: 179 additions & 0 deletions

File tree

docs/plans/scratchpad.md

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
# Scratchpad tabs (GH #244)
2+
3+
Sublime-style untitled buffers, inside termic. Open one from the tab strip,
4+
type a note, and it is still there after a relaunch — without ever choosing a
5+
filename, and without a stray file appearing in `git status`.
6+
7+
## The rule everything else follows
8+
9+
**A scratchpad is an unsaved buffer that happens to survive restarts.**
10+
11+
It is NOT a file with a hidden path. ⌘S does not write to the scratch store: it
12+
**promotes** the buffer to a real file inside the project (pick a folder, pick
13+
a name), after which it is an ordinary `edit` tab and the scratch record is
14+
gone. Getting this backwards — having ⌘S quietly write to `~/…/scratch/` — is
15+
the one way to ruin the feature: the user's muscle-memory save would report
16+
success and file the note somewhere they will never look again.
17+
18+
Two consequences that read as contradictions until you hold the rule:
19+
20+
- **Quitting keeps your pads. Closing one asks.** A relaunch restores every
21+
open pad untouched; closing a tab prompts *Save… / Discard / Cancel*, and
22+
Discard deletes the pad. Same as Sublime, and it is what the issue thread
23+
asked for. Persistence covers the relaunch case, not the explicit close.
24+
- **A pad is dirty for its whole life.** The dot on the tab is honest: nothing
25+
has been saved anywhere the user chose. The debounced write to the scratch
26+
store below is crash safety, not saving, and must not clear the dot.
27+
28+
## Storage
29+
30+
Real files under the Rust `data_dir()` (`src-tauri/src/lib.rs`), never inside
31+
the worktree — a scratch file in the repo shows up in `git status`, in the diff
32+
the agent reviews, and eventually in a commit.
33+
34+
```
35+
<data_dir>/scratch/<projectId>/index.json one record per pad
36+
<data_dir>/scratch/<projectId>/<scratchId>.txt the buffer
37+
```
38+
39+
`index.json` holds, per pad: `id`, `title`, `syntax`, `taskId`, `order`,
40+
`createdAt`, `updatedAt`. Title and syntax have to survive a relaunch and there
41+
is no filename to re-derive them from, and one index read beats stat-ing N
42+
files on launch.
43+
44+
**Scoped to the PROJECT, not the task.** This is the direct answer to the
45+
objection in the issue thread ("they will stick to one session… the task
46+
accidentally disappears"): a note about work that outlives a task must outlive
47+
the task. `taskId` is recorded so the pad reopens in the tab strip it was last
48+
used in, but archiving that task must not delete it. Concretely: pads may not
49+
be keyed by task in any map that `loadAll`'s prune walks (the trap
50+
`store/fileViewed.ts` documents in [gotchas.md](../gotchas.md)).
51+
52+
## Rust commands
53+
54+
All async (`spawn_blocking`), per the IO rule in [ipc.md](../ipc.md).
55+
56+
| command | does |
57+
| --- | --- |
58+
| `scratch_list(project_id)` | the index, ordered |
59+
| `scratch_read(project_id, id)` | buffer contents |
60+
| `scratch_write(project_id, id, content)` | create-or-overwrite the buffer, stamp `updatedAt` |
61+
| `scratch_set_meta(project_id, id, {title, syntax, task_id, order})` | index-only update |
62+
| `scratch_delete(project_id, id)` | drop buffer + record |
63+
| `scratch_promote(project_id, id, task_id, rel_path)` | write the buffer to a task-relative path, then delete the pad |
64+
65+
`scratch_promote` is one command on purpose: promotion must resolve the target
66+
through the same `resolve_task_git_path` + `safe_task_path` pair every other
67+
write uses (so member dirs work and nothing escapes the worktree), and doing it
68+
as "read here, write there" from TypeScript would re-implement that rule in the
69+
one place it must not be re-implemented.
70+
71+
## Frontend model
72+
73+
Add `"scratch"` to `TabType` (`src/lib/types.ts`) with its own interface:
74+
75+
```ts
76+
export interface ScratchTab extends BaseTab {
77+
type: "scratch";
78+
scratchId: string;
79+
projectId: string;
80+
/** Manual "Set syntax" pick. PERSISTED here, unlike EditTab's session-only
81+
* one: a pad has no extension to re-derive from. */
82+
syntax?: string;
83+
}
84+
```
85+
86+
A new type rather than an `EditTab` with an empty `path`, because `EditTab.path`
87+
is load-bearing in places a pad must opt out of — inline blame, review
88+
comments, the on-disk-changed banner, "locate in file tree", the breadcrumb.
89+
Making it a distinct type turns every one of those into a compiler error to
90+
answer rather than a runtime surprise.
91+
92+
**Reuse EditorPane, do not fork it.** Give it a `source` prop:
93+
94+
```ts
95+
type EditorSource =
96+
| { kind: "file"; taskId: string; path: string }
97+
| { kind: "scratch"; projectId: string; scratchId: string };
98+
```
99+
100+
`kind: "scratch"` swaps `taskFileRead`/`taskFileWrite` for the scratch IPC and
101+
turns off blame, review comments, and the disk-watch reload (`fsRevision` /
102+
window-focus). Everything else — the CodeMirror setup, the theme and language
103+
compartments, find, ⌘S binding — is shared. A second editor component would
104+
drift from the first within two releases.
105+
106+
## Behaviour
107+
108+
**Creating.** The tab strip's "+" menu gains a "Scratchpad" row
109+
(`NewTabMenuItems.tsx`, which the sidebar task row's New submenu also renders,
110+
so both entry points get it), plus a command-palette row. ⌘N is already new-task
111+
(`lib/shortcuts.ts`), so the shortcut is a new rebindable id (⌥⌘N by default),
112+
not a steal.
113+
114+
**Titling.** Derived from the first non-empty line, trimmed to ~40 chars;
115+
"Untitled" while empty. Debounced (~500ms) and **bailing when unchanged** — this
116+
runs on the typing path, which is exactly [performance.md](../performance.md)
117+
bear trap 8. A double-click rename sets `customTitle` and stops derivation, the
118+
same lock that keeps OSC titles from steamrolling a renamed terminal tab.
119+
120+
**Syntax.** A pad has no path, so `languageIdForPath` returns null and the
121+
content sniffer (`lib/detectSyntax.ts`) answers instead; the Set-syntax picker
122+
overrides it. Both already shipped (9f8b487) and need no changes — only the
123+
persistence of the manual pick in the index.
124+
125+
**Saving (the defining flow).** ⌘S opens an in-app "Save to project" picker:
126+
the task's folder tree plus a filename field, prefilled with a slug of the
127+
derived title. Deliberately NOT the native save panel — the requirement is
128+
*inside the project*, and a native panel can write anywhere. On confirm:
129+
`scratch_promote`, the tab becomes an `edit` tab on that path, then
130+
`bumpFsRevision` + `bumpGitRevision` so the file tree and Git panel notice.
131+
An existing file at the target asks before overwriting.
132+
133+
**Closing.** Route through `lib/closeTab.ts` — it is already the single close
134+
path for the strip ×, pane ×, and ⌘W, and it already special-cases dirty edit
135+
tabs. A pad needs a THREE-way prompt (Save… / Discard / Cancel), which today's
136+
`askConfirm` cannot express; extend it with an optional third action rather
137+
than hand-rolling a second confirm dialog.
138+
139+
**Restoring.** From the project's scratch index on launch (each record carries
140+
`taskId` and `order`), NOT from `persisted_tabs` — that record is agent-tabs-only
141+
by construction and pads are project-owned, not task-owned. Trade-off accepted
142+
for v1: a pad restores into its task's main strip, and split-pane position is
143+
not remembered.
144+
145+
## Traps
146+
147+
- **Never set `preview: true` on a scratch tab.** `openPreviewTab` recycles the
148+
first tab it finds carrying that flag, and recycling a pad would silently
149+
retarget it at a file (see the same function's `syntax`/`syntaxAuto` reset).
150+
- **The debounced buffer write and the title derivation are both on the typing
151+
path.** Debounce both, bail on unchanged, and never write an unchanged value
152+
through a store setter (bear trap 8).
153+
- **`data_dir()` is also the e2e profile.** `scripts/e2e-seed.mjs` and
154+
`wdio.conf.ts`'s `onPrepare` must sweep `scratch/` the way they sweep
155+
`tasks/`, or pads leak between local runs and specs start seeing each other's
156+
notes.
157+
- **Archiving a task must leave its pads alone.** Assert it.
158+
159+
## Testing
160+
161+
- **Unit (vitest):** title derivation (first line, truncation, empty buffer,
162+
unchanged-bail), index round-trip, and the tab-type guards that keep blame /
163+
review comments / disk-watch off a scratch source.
164+
- **Rust (`cargo test`):** scratch CRUD, and `scratch_promote`'s containment —
165+
a `rel_path` that escapes the worktree must be refused, including through a
166+
member dir.
167+
- **e2e (new `scratchpad.e2e.ts`):** create from the + menu → type → the tab
168+
title follows the first line → the syntax button names what the content
169+
sniffer picked → ⌘S opens the picker → save → it is an `edit` tab on a real
170+
path, the file exists on disk, and the Git panel lists it. Plus: closing with
171+
Discard removes the pad, and closing with Cancel keeps the tab. Restore
172+
across a relaunch is a store-level test, not an e2e one — the suite shares a
173+
single app launch.
174+
175+
## Out of scope for v1
176+
177+
Split-pane pads and pane-position restore; global (cross-project) pads; the
178+
checklist / task-tracker evolution floated at the end of the issue; anything
179+
that syncs pads between machines.

0 commit comments

Comments
 (0)