Skip to content

Add Surface notepads with capture, archive, and lifecycle preservation - #570

Merged
nedtwigg merged 35 commits into
mainfrom
notepad-2
Sep 6, 2026
Merged

Add Surface notepads with capture, archive, and lifecycle preservation#570
nedtwigg merged 35 commits into
mainfrom
notepad-2

Conversation

@nedtwigg

@nedtwigg nedtwigg commented Sep 5, 2026

Copy link
Copy Markdown
Member

Add a notepad to each Surface so users can keep plain notes and styled terminal selections, navigate back to captured output, and find notes in a machine-local archive after the Surface closes. Pane headers and minimized Doors open notes; Settings provides the Archive view with staged deletion and recovery of unreadable data.

Helpers share their parent’s notepad and capture without source pins. Reset, hide, and promotion preserve the shared notes on the parent. Closure checks helper work before and after archiving, freezes edits during the transaction, and tears down only after every gate succeeds. The floating context preserves main’s animation, focus behavior, and helper lifecycle fixes. Late helper startup is cancelled when its parent retires, command reuse excludes actual closures, and failed or cancelled dor ensure panes archive user-entered notes before removal.

The archive uses compare-and-swap transactions across processes. VS Code mirrors live notes in host memory for editor-tab disposal and same-host resumes, and stores the archive in an atomically replaced shared file with legacy migration and locked recovery. Stable pending batch IDs preserve edits and deletions when a save reply times out. Standalone quit archives before teardown with an explicit failure decision.

Validation: full pnpm test, pnpm build, and all 29 Rust unit tests passed after merging origin/main. A live Storybook browser check verified creating a note in helper context and reopening the same note from the parent. Specs and rationale accompany the implementation.

nedtwigg and others added 25 commits September 4, 2026 15:52
The plan with its resolved open questions, the note/archive types shared by
the webview and the VS Code extension host, the idempotent archive mutation
with strict validation of stored data, and the optional PlatformAdapter port
every notepad-capable host implements.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HqBmeMGWo1P98V3mFpsc1Y
Rich extraction over xterm buffer cells with theme-resolved colors, the
plain+HTML clipboard exporter, terminal source pins backed by buffer
markers, the per-Surface live-note store with its volatile mirror, and the
compare-and-swap archive service. Hosts: an owner-only atomic JSON file
behind Tauri commands, a globalState entry behind a serialized extension-host
queue with the in-memory mirror that hydrates a live resume and is archived
on editor-panel disposal and deactivation, and an in-memory port for the
fake adapter and the browser-dev harness.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HqBmeMGWo1P98V3mFpsc1Y
The header's notepad icon and the 75% attached panel, the Door split into a
reattach button and a notepad button with an anchored popover, one NoteList
with direct rich-note editing that converts to plain text on the first content
mutation, the selection popup's Add to notepad with the Cmd/Ctrl+N chord, the
pin reveal, and the Settings-reached Archive view with staged deletions,
Undo, commit-on-leave, and the unreadable-archive recovery.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HqBmeMGWo1P98V3mFpsc1Y
… spec

Every user-visible permanent closure (header and keyboard kills, dor kill,
the door-restore kill) archives a Surface's notes in one idempotent append
before teardown, with Keep open / Close anyway on failure; in-place
replacement migrates notes to the new id; Standalone archives as a bounded
gate step before quit_progress with Cancel / Quit anyway; a live resume
hydrates from the VS Code mirror and a cold restore never does; the website
demo binds no chord. docs/specs/notepad.md owns the feature, with pointers
from layout, mouse-and-clipboard, shortcuts, transport, standalone, vscode,
security-local, and security.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HqBmeMGWo1P98V3mFpsc1Y
One NotepadBody behind the panel and the Door popover, one header button,
one platform-availability gate, the existing serial queue in both archive
layers, a single mutation for the mirrored archive on VS Code teardown, and
closeSurface returning its own failure reason to dor kill. Also un-binaries
the volatile mirror module, whose map-key separator was a raw NUL byte.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HqBmeMGWo1P98V3mFpsc1Y
Staged archive deletions commit when their webview is disposed instead of
riding the mirror to shutdown; batch ids are minted per closure attempt and
appends are idempotent by note id, so a write that landed but reported
failure neither duplicates nor drops notes; a quit archive that outlives its
deadline is aborted before it can empty the live notepads; an archive
failure leaves the quit pending so the Rust watchdog survives Quit anyway;
refused closures queue one prompt at a time; dor kill raises none; word,
line, and IME edits on a rich note convert with the edit applied; the VS
Code boot mirror is validated like the archive.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HqBmeMGWo1P98V3mFpsc1Y
PLAN.md was the build plan for the notepad feature; docs/specs/notepad.md and
its rationale are now the reference, and git keeps the plan.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LRmkCJNVZA3qVL6GGNbPQA
The archive readers projected known fields and dropped the rest, but every
mutation loads, applies, and writes the whole archive back — so a field written
by a newer build was silently erased by this build's next save, and `version`
only guards breaking changes. Each reader now refuses a record carrying a key
outside its allowlist, which turns that case into the reported-unreadable state
the spec already requires instead of silent data loss.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LRmkCJNVZA3qVL6GGNbPQA
`archiveSurfaceNotes` snapshotted the notes, awaited the host, and then
called `removeSurface`. The panel stayed interactive across that await, so
a note added mid-write was deleted having never been archived, and an edit
was worse: `setNoteText` replaced the note object, so the archive kept the
pre-edit text and the forget step took the edit with it.

The store now carries a counted `closingSurfaces` set with `beginClosing`
returning its release. While a Surface is frozen every content mutation
refuses — `addPlainNote`/`addTerminalNote` answer `null` (the capture's
markers released, since no note will own them), `setNoteText`,
`deleteNote`, `pruneEmptyNote`, `dropSource` and `transferNotepad` do
nothing — while `removeSurface`, `dropSourcesForTerminal`,
`hydrateNotepadFromVolatile` and `clearAllNotepads` keep working for the
coordinator and the teardown paths. The freeze rides the existing
`notify()`, so `useSurfaceClosing` puts the panel into a read-only state
behind an "Archiving notes…" line, with Add New disabled and both editors
refusing input.

The coordinator freezes before it reads a single note and releases in a
`finally`, and empty plain notes are now filtered out of a batch — an
untouched Add New still focused when the kill lands is not a note, and a
Surface holding only those closes as if it held none.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LRmkCJNVZA3qVL6GGNbPQA
A write that landed and then reported failure (the VS Code adapter's
request timeout) leaves the Surface open, so the user keeps working in it.
A fresh batch id per attempt plus dedupe by note id kept the notes added
since, but not an edit to one already stored: `applyArchiveMutation`
recognized the note id and dropped the edited copy, so the archive kept
the pre-edit text and `removeSurface` then discarded the edit.

The store now remembers a batch id per Surface (`pendingBatchId`), carried
across a `transferNotepad` and forgotten by `removeSurface`, and the
coordinator sends `{ deleteBatchIds: <the ids it is appending>, append }`.
On a first attempt the delete is a no-op; on a retry it replaces the
landed batch with the current notes, so edits, additions, and the user's
own deletions all survive. `closedAt` stays per attempt, and the
`AbortSignal` path leaves the id in place so the next close replaces
rather than duplicates.

`applyArchiveMutation` therefore applies batch deletes, then note deletes,
then appends, with the batch-id and note-id sets read after the deletes so
the batch being replaced no longer counts as storing its own notes. The
note-id dedupe stays for the VS Code mirror path, which still mints a
fresh id per teardown.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LRmkCJNVZA3qVL6GGNbPQA
DialogKeyboardContext was a single boolean setter over one shared ref, so
two overlapping dialogs shared one flag and whichever closed first released
the other's suppression — command-mode keys then fired under an open dialog.
It is now an acquire-a-lease call: the Wall reference-counts the leases and
command-mode dispatch resumes only when the last one is released. Every
consumer takes its own lease through useDialogKeyboardOwner, or through
useDialogKeyboardCallback where the component's prop is still a boolean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LRmkCJNVZA3qVL6GGNbPQA
…vision

`pnpm dev:standalone` and the installed app share a Tauri identifier, so they
share `app_data_dir()` and this archive; the compare-and-swap revision was a
process-local counter that never saw the other process's write, and the later
save silently overwrote the earlier one's batches. The revision is now a hash of
the stored bytes, and every load, save and reset holds an exclusive lock on a
sidecar `notepad-archive-v1.lock` — a separate file because the archive itself is
replaced by rename — so the loser of an overlapping load-save is told "conflict"
and retries.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LRmkCJNVZA3qVL6GGNbPQA
An attempt that reached the host and *then* reported failure leaves its
batch stored and its id remembered. If the user answered Keep open by
deleting every note and closing again, `archiveSurfaceNotes` found
nothing to append, wrote nothing, and the landed batch survived holding
exactly the notes the user deleted after being told none were archived.

A Surface with no archivable notes but a remembered id now contributes
that id to the closure's single mutation as a delete, and forgets it
only once the mutation lands. The no-port path is unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LRmkCJNVZA3qVL6GGNbPQA
A batch archived whatever CWD the Session last reported, which for a
shell with no CWD escapes (no OSC 7 / 9;9 / 633 / 1337) is nothing at
all — even though the PTY is alive at that moment and the host can
answer `getCwd` for it, the same fallback the session-save path uses.

The close coordinator now asks the host about every terminal Surface
with archivable notes, concurrently, under the freeze and before the
first batch reads its metadata. Bounded at PROCESS_CWD_REFRESH_MS, with
rejections and timeouts keeping the reported CWD; `fillTerminalProcessCwd`
already refuses to override an integration-owned one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LRmkCJNVZA3qVL6GGNbPQA
…eardown

The extension host archives from its mirror on an editor-panel disposal
and in `deactivate()`, with whatever cwd the webview last mirrored —
`null` for a shell with no CWD escapes — even though the PTY is alive at
that moment and `ptyManager.getCwd` can answer for it.

`VolatileSurfaceNotes` now carries the Session's PTY id for terminal
Surfaces, mirror-only: `buildVolatileSnapshot` sets it from
`resolveTerminalSessionId`, `sanitizeSurface` and `readSurface` carry it
through, and `batchFromVolatile` never copies it into a batch. The pure
`refreshMirrorCwds` fills a `null` / `process` / `manual` cwd from the
live PTY, bounded, failures ignored.

On the `killOnDispose` path the kills now run in a `finally` after the
write, because the loop used to kill the PTYs the refresh was about to
ask about. Every other bookkeeping step stays synchronous and where it
was, and the non-killing `WebviewView` path is unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LRmkCJNVZA3qVL6GGNbPQA
`resolveTerminalSource` read an active alternate buffer as rows out of
range, so opening a note's pin while `less` or a TUI was up reported
`missing-rows` and `revealNoteSource` dropped the pin — disposing markers
that were still valid on the normal buffer underneath.

Check the buffer type first and report `alternate-buffer`, the one failure
`revealNoteSource` does not drop the pin for. The UI carries a
`sourceNotice` of `unavailable` or `alternate-buffer` instead of a bare
note id, so the row can say to exit the full-screen program while the pin
button stays for the retry.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LRmkCJNVZA3qVL6GGNbPQA
The render handler drops a finalized Dormouse selection once the text
under it changes, but only while a baseline is armed — which the mouse
router does at drag end and nothing else did. A selection restored from a
note's pin therefore sat on its rows through any later output.

Expose the entry's setter as `setTerminalSelectionBaseline` and have
`revealResolvedSource` arm it with the text the resolve step just proved
equal to the pin's raw text.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LRmkCJNVZA3qVL6GGNbPQA
- One dialog-keyboard hook: `useDialogKeyboardOwner(active, acquireOverride?)`
  replaces `useDialogKeyboardCallback` and five `onKeyboardActiveChange` props.
- One bounded-batch helper, `settleAllWithin` in `lib/src/lib/settle-within.ts`:
  one timer per batch, not one per Surface, on both CWD refresh paths.
- One CWD-precedence predicate, `processCwdMayReplace`, and the closure no longer
  runs `lsof` for an answer `fillTerminalProcessCwd` would discard.
- Flatten the coordinator: notes read once under the freeze, one `deleteBatchIds`
  array, the no-archive path hoisted above `beginClosing`.
- One definition of an empty note, `isEmptyPlainNote`.
- The freeze gets its own listeners, so it stops re-rendering every Door and
  re-posting the volatile mirror.
- One pin-outcome classification: `PinOutcome.kept` and `sourceNoticeFor`.
- Key allowlists pinned to their types, so a new `CwdState` field fails `tsc`
  instead of making every stored archive unreadable.
- One mirror-only `terminalId` reader, `readMirrorTerminalId`.
- Rust: one `ArchiveLock` RAII guard, `with_extension("lock")`, and a shared
  `ensure_owner_only_parent`.
- `dispose()`: `toKill` computed up front, the notepad write one expression.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LRmkCJNVZA3qVL6GGNbPQA
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 5, 2026

Copy link
Copy Markdown

Deploying mouseterm with  Cloudflare Pages  Cloudflare Pages

Latest commit: 06366f1
Status: ✅  Deploy successful!
Preview URL: https://ccb076e6.mouseterm.pages.dev
Branch Preview URL: https://notepad-2.mouseterm.pages.dev

View logs

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Read the whole thing — the notepad model, the three archive hosts, the close coordinator, the VS Code volatile mirror, the Rust store, and the specs. It holds together well: the compare-and-swap contract is stated once and honoured identically by all three ports, applyArchiveMutation is genuinely idempotent in both directions, and the delete-then-append replacement is the right shape for "landed, then reported failure". The dialog-keyboard lease refactor is a real bug fix riding along. I also re-read the delta since I started (the main merge that dropped resolveTerminalSessionId, and 1f08b218).

One thing I think is a gap, plus three small ones inline.

The standalone quit gate can't reach the coordinator's "delete the landed batch" branch. archiveNotesBeforeQuit derives its Surface list from getNotepadSnapshot(), which only holds Surfaces with at least one live note — replaceNotes deletes the map entry when the last note goes. So a Surface that still has a pendingBatchId but no notes never reaches archiveSurfaceNotes, and peekPendingBatchId never runs for it.

That combination is reachable, and it is exactly the case the spec's rule covers: the 3 s withDeadline fires, gaveUp.abort() stops the forget step but the mutation lands anyway, the user picks Cancel, then deletes every note in that notepad. docs/specs/notepad.md → "Closure" says the batch should go with them — "its notes having been deleted after the user was told none were stored" — but on the next quit the stored batch survives. close-coordinator.test.ts's "deletes the landed batch when the retried closure has nothing left" pins the coordinator; quit.test.ts mocks getNotepadSnapshot, so nothing pins the caller.

Wall.closeSurface isn't affected — it passes an explicit id. It's only the one caller that infers the list.

Comment thread standalone/src/quit.ts Outdated
Comment thread lib/src/lib/notepad/close-coordinator.ts Outdated
Comment thread lib/src/lib/notepad/notepad-store.ts Outdated
Comment thread vscode-ext/src/notepad-volatile.ts Outdated

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All four earlier threads are genuinely closed. I re-derived the quit path with the real store: notepadSurfaceIds() is the right home for the union, quit-notepad.test.ts reaches the branch through the real coordinator rather than a mock, the async wrapper turns a synchronous getCwd throw into a per-entry settleAllWithin fallback, and both comments now match the code. The dor alias in standalone/vitest.config.ts is what the real notepad-store import needs, and it matches the dor/* path standalone's tsconfig already carries.

One leftover, non-blocking.

Comment thread standalone/src/quit.ts
nedtwigg and others added 4 commits September 5, 2026 15:44
Working-tree refinements restored after the origin/main merge, plus the
fixes from the combined-PR review:

- restartSurfaceInPlace re-checks the abort signal after each await: an
  already-satisfied wait resolves without polling, so a cancel queued ahead
  of that continuation slipped past and typed the command (regression test).
- closeSurface's re-entrancy latch is Wall-owned again; the notepad store's
  isSurfaceClosing is the shared notes freeze, which the standalone quit gate
  holds past its deadline and would have left every noted Surface unclosable.
- requestKill drops a gesture whose helper inspection outlived the pane
  instead of staging a confirm overlay for a dead Surface (test).
- Four kill-gesture tests in Wall.test.tsx replace the keyboard-layer ones the
  refactor removed: untouched fast path, confirm staging, both Door branches.
- closeHelperParent marks a retiring parent so a late openHelper reply cannot
  spawn an unreachable helper PTY; replaceSurface and shell replacement now
  dispose the retired parent's helper.
- revealRefusal reattaches a Door in command mode, like requestKill.
- dor ensure / dor ab lookups skip Surfaces mid-closure as well as mid-fade.
- Helper parking div is revalidated (isConnected) rather than cached forever.
- discard mode asks the helper guard once, not twice back to back.
- messageOf moves to lib/src/lib/errors.ts; TerminalContextView's inline
  copy (blank on an empty-message Error) uses it.
- Specs: layout.md names requestKill; notepad.md states that Close anyway
  and Quit anyway leave a landed batch in place; budgets ratcheted.
- Story passes NotepadPanel through the notepadPanel prop like the live view.
- use-popover-focus-trap.ts is tracked again (NotepadBody imports it).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VfGfWNvMKTz2ZKnyJgbtpT

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Read the increment since b02f9674: the two authored commits plus the origin/main merge resolutions. The kill-gesture consolidation into requestKill is a real simplification — the keyboard layer no longer re-derives door/untouched policy — the Wall-owned pendingSurfaceCloses vs. the notepad store's shared notes freeze is the right seam, and routing dor ensure's integration-timeout cleanup through closeSurface closes a genuine note-loss hole. The merge resolutions check out too: pointInConvexPolygon goes with its last caller, and TerminalPanel / wall-context / ui-geometry land on the post-#573 shapes.

One finding.

docs/specs/layout.md still says the dor ensure throwaway is torn down with killPaneImmediately. Under "Spatial navigation", item 6 ("Focus-neutral surface creation") ends:

A throwaway that never reports OSC 633 integration is torn down with killPaneImmediately, whose live selection check leaves the caller's selection intact (a --minimize throwaway is already a door, disposed directly).

398ff0fa made that path await closeSurface(result.value.id, 'silent'), and the same commit's docs/specs/notepad.md → "Closure" states the opposite as a rule: Must route dor ensure cancellation and integration-timeout cleanup through closeSurface. layout.md's own new rule under "Kill confirmation" — "Every kill routes through the notepad close coordinator … and can refuse the close" — contradicts it as well. The sentence is also unconditional where the behavior no longer is: a refused archive keeps the throwaway open and answers the caller with temporary surface kept open: ….

The line is outside the diff hunks, so no inline suggestion. A replacement that fits the budget (layout.md is at 7939 of 7950 words):

A throwaway that never reports OSC 633 integration closes through `closeSurface`, whose teardown's live selection check leaves the caller's selection intact (a `--minimize` throwaway is already a door, disposed directly); a refused archive keeps it open.

Say the word and I'll push it.

Not a finding, just a heads-up: #574 rewrites ContextAction's busy/disabled handling in the same TerminalContextView.tsx that 1d0987e5 restructures around, so whichever lands second will need the other's edit reapplied.

What I checked, and what I ruled out
  • closeHelperParent dropping its promoting exemption in 398ff0fa means it now throws out of disposeHelper, and none of its three call sites handles that. Unreachable in practice: closeSurface gates on helperRefusal first and helperHasWork returns true while promoting, and both replace paths are behind isReplaceableShell, which is isUntouched(id) && !getHelper(id). Worth knowing that the assert can only fire somewhere it cannot recover — removeSurface(id) has already dropped the notepad by the time killPaneImmediately runs.
  • usePopoverFocusTrap vs. the private useModalFocusTrap in design.tsx: not duplication worth collapsing. They already share stepFocus, which focus-step.ts documents as the intended shared primitive, and the popover adds outside-click dismissal and scopes its handler to focus inside the popover.
  • SelectionPopup.copy.test.tsx folding into SelectionPopup.test.tsx preserves all five cases; the it.each Helper-closure test splits into four named tests with more coverage, not less.
  • pointInConvexPolygon disappears in the origin/main merge because PaneHeaderContextMenu did; nothing references it anywhere.
  • reconnect.ts's ptyById map is equivalent to the old ptyList.some(...) scan, and still adopts an orphaned helper on the else branch.
  • scripts/spec-lint.mjs passes on the PR's real tree. (It fails in this checkout with spec not indexed -> docs/specs/notepad.md, because the CI harness restores AGENTS.md from the base branch before the session starts — git show HEAD:AGENTS.md has the index entry.)

@nedtwigg
nedtwigg merged commit 4b89bf2 into main Sep 6, 2026
10 checks passed
@nedtwigg
nedtwigg deleted the notepad-2 branch September 6, 2026 07:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants