Skip to content

feat(annotate): one-step submit with a quick note - #1436

Open
backnotprop wants to merge 1 commit into
mainfrom
feat/annotate-submit-with-note
Open

feat(annotate): one-step submit with a quick note#1436
backnotprop wants to merge 1 commit into
mainfrom
feat/annotate-submit-with-note

Conversation

@backnotprop

Copy link
Copy Markdown
Owner

Why

A user asked for a one-step "that's fine" submit on /plannotator-last: they read the agent's message and want to send a quick overall note without opening the annotation machinery. Today that is four interactions (open the global-comment composer, type, save, Send Annotations), and with no annotations at all the Send button is not even rendered, so there is nothing to press.

What ships

Every annotate surface now has a split Send control:

  • With feedback queued the primary button is the incumbent Send Feedback, unchanged. The caret beside it opens a one-line Add a note... field, and sending from there submits the note together with the queued annotations in one action.
  • With nothing queued the primary button opens that field directly. This replaces the incumbent behaviour of hiding the button entirely, which is the right guard to replace: submitting an empty review was never useful, so there was nothing to press.
  • Enter sends, Escape closes the field without submitting and keeps the typed text for the rest of the session.

Surfaces covered: annotate (file), annotate-last (message, single and multi-message), annotate-folder, annotate-app (live), and URL sessions. Plan mode is deliberately untouched.

Zero server changes, verified

The note is created as a GLOBAL_COMMENT at submit time, so it is just another annotation on the wire. Both /api/feedback handlers were read to confirm nothing else is needed:

  • packages/server/annotate.ts reads the body as { feedback: string; annotations: unknown[]; ... }, settles the decision with them, and passes the same pair to persistSubmittedDecision. The annotations array is opaque; no type or shape is inspected.
  • apps/pi-extension/server/serverAnnotate.ts mirrors that exactly, including the same "does not type-validate its body" comment and the same Array.isArray(annotations) degradation.

The exported feedback string is produced client side by exportAnnotations, which already renders GLOBAL_COMMENT entries. So the note reaches the agent through the text and is persisted in the durable submission record, with no endpoint, schema or runtime change on either the Bun or the Pi side. No server file is touched by this PR.

How it is wired

The note is committed into the annotations state (commitSubmitNote in App.tsx) rather than threaded through the payload builders. That is deliberate: annotate-last's multi-message export rebuilds its per-message entries from the live linked-doc session snapshot, not from allAnnotations, so a note spliced into the builder call would be silently dropped there. Committing into state makes every annotate export path pick it up for free. Because that commit is a state write, the submit waits one render for it, via an effect keyed on the pending note id.

Other decisions:

  • Undo/redo (feat: add bounded annotation undo and redo #1426): the note is deliberately not recorded in the annotation history. It exists for the duration of one submit, and an undo after the send would restore nothing the agent has not already been told.
  • HTML and live-app surfaces: the comment-only clamp does not apply. That clamp sits on the parent's postMessage ingest of iframe selections (useHtmlAnnotation), and this note is created in the parent App, never posted from the page. Verified in code and covered by a test on the raw-HTML surface.
  • Draft persistence: an unsent note is not drafted. It becomes a real, drafted annotation the moment it is sent, and a one-liner is cheaper to retype than a second draft channel is to maintain. Escape keeps the text in the control for the rest of the session, so the loss window is a page reload only. A failed submit leaves the note as a committed annotation, so nothing is lost there either.
  • Compact touch shell: it renders no header Send control, and the compact decision list's action ids are a closed union that a text field cannot live in, so the field is a small always-expanded section in the "Review and finish" surface instead. It does not autofocus, so opening Review does not raise the keyboard.
  • feedbackTemplates: untouched. Templates wrap the finished feedback string on delivery; the note is inside that string like any other comment.

Footprint

App.tsx grows by one memoised control object, one commit helper, one submit handler and one effect. The affordance itself is a new packages/editor/components/AnnotateSendControl.tsx, following the auto-viewed and global-comments precedent. Nothing in @plannotator/ui changes behaviour: the only edit there is a new shortcut scope plus its barrel export, and AppHeader's new submitNote prop is optional, so a header rendered without it keeps today's markup exactly.

Enter and Escape are handled locally on the input, and the annotate-note scope documents them in the annotate registry only. This follows the comment-popover precedent, which is documentation only with local handlers. Enter rather than Mod+Enter is a deliberate choice for a one-line field where a bare Enter has no other job.

Tests

New packages/editor/App.submitNote.test.tsx, six tests, each named after the regression it guards:

  1. Zero annotations: Send opens the note field and submits nothing.
  2. Enter sends the note as a GLOBAL_COMMENT, present in both the /api/feedback annotations array and the exported feedback text.
  3. The note rides alongside an annotation already in the session, and neither is dropped.
  4. Escape closes without submitting and preserves the typed text.
  5. The HTML surface sends the same one-step note (the comment-only clamp does not block it).
  6. Plan review grows no note control and its Send still behaves as before.

Falsified: removing the header wiring fails 5 of the 6, and the plan-mode test correctly keeps passing.

Results:

  • DOM_TESTS=1 bun test packages/editor packages/ui : 1476 pass, 0 fail
  • bun test packages/editor packages/ui : 880 pass, 0 fail
  • bun run typecheck : clean
  • bun test (whole repo): 4104 pass, 12 fail, all pre-existing and unrelated (semantic diff, GitButler and workspace review-server tests, which fail identically on a clean tree)
  • bun run --cwd apps/review build && bun run build:hook : both succeed, build order respected

AI-assisted (Claude) under maintainer direction.

Reading an agent's message and wanting to reply "that's fine, but watch
the migration" took four interactions: open the global-comment composer,
type, save, then Send. Every annotate surface now has a split Send
control whose caret opens a one-line "Add a note..." field. Enter sends
the note together with any annotations already queued, in one action.

With nothing queued the primary Send button opens that field instead of
staying hidden, which is what the header did before (submitting an empty
review was never useful). With feedback present the primary button is
the incumbent Send Feedback, unchanged. Escape closes the field without
submitting and keeps the typed text for the rest of the session.

The note is created as a GLOBAL_COMMENT at submit time and committed
into the annotations state, so it rides exportAnnotations and the
/api/feedback annotations array exactly like a composer-made global
comment. Both runtimes' /api/feedback handlers take a pre-rendered
feedback string plus an opaque unknown[], so there are no server
changes. Committing into state rather than threading the note through
the payload builders is what makes annotate-last's multi-message export
pick it up, since those entries are rebuilt from the live linked-doc
session snapshot; the submit therefore waits one render for the commit.

The note is not recorded in the annotation undo/redo history: it exists
for the duration of one submit. On HTML and live-app surfaces the
comment-only clamp does not apply, because that clamp sits on the
iframe's postMessage ingest and this note is created in the parent.
Plan mode is untouched. The compact touch shell has no header Send
control, so the field lives in its "Review and finish" surface.

Covers: file, folder, annotate-last, URL and live-app sessions.
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.

1 participant