Skip to content

Headless docx redlining — Patrick edits files, Word is the review surface - #169

Merged
mhurhangee merged 10 commits into
mainfrom
spike/docx-redline-js
Jul 4, 2026
Merged

mhurhangee merged 10 commits into
mainfrom
spike/docx-redline-js

Conversation

@mhurhangee

Copy link
Copy Markdown
Owner

What

Removes the in-app docx editor from Patrick's critical path. Patrick now edits .docx files on disk as native Word tracked changes (headless reconciliation via MIT-licensed @ansonlai/docx-redline-js), and Word/LibreOffice is the viewing + review surface. The three vendored @eigenpal/docx-editor-* packages are no longer imported by any app — their deletion is the follow-up PR, keeping this one revertible.

How

  • Engine adapter (apps/api/src/lib/docx/redline.ts) — the only import point for the redline library, guarding its measured sharp edges: exact unambiguous paragraph resolution (its fuzzy matcher can silently redline the wrong paragraph), supersede-on-re-edit (stacked redlines double-apply on accept), post-verify before write (a failed edit never mutates), ghost w:rPrChange stripping.
  • The dance (apps/api/src/lib/docx/dance.ts) — sharing a file with Word by protocol: reads never block; writes apply when the draft is closed and park while a Word/LO lock marker exists, draining the moment it's closed. All mutations serialize through a per-draft queue (parallel tool calls once collapsed 12 comments to 3). Save-triggered @Patrick comment pickup makes Word comments an instruction channel.
  • Four server-executed draft tools (read_draft / edit_paragraph / add_draft_comment / read_draft_comments) replace the seven no-execute editor tools and the whole client round-trip.
  • DraftPanel replaces the editor UI: live preview with pending redlines rendered as ins/del marks, dance status (lock / parked count / mentions / failures), Open in Word.
  • Unlock in place — requestUnlock now flips the original itself to editable (pristine bytes snapshotted to .patrick/backups) instead of minting a "(Patrick) copy"; safe because every edit is a rejectable redline and untouched XML stays byte-identical.

Testing

  • 1,763 tests green, including new suites for the adapter (accept→new / reject→original round-trips on a real USPTO office action, supersede, ambiguity, ghost-strip), the dance (lock detection, park/drain, 12-parallel-ops regression, mentions), and unlock backups.
  • Field-tested against a real office action: comments, redlines, parked edits, LibreOffice lock choreography.
  • Outstanding before the teardown PR: Windows Word checkpoint on real output.

Evaluates @ansonlai/docx-redline-js (MIT) as the tracked-changes engine
for the post-editor direction: edit .docx on disk, review in Word, no
in-app editor. spike.ts exercises redline + comment + accept/reject
round-trips on a minimal generated docx; spike2.ts does the same on a
real USPTO office action (bring your own .docx, gitignored) and measures
collateral damage.

Findings: runs under Bun with @xmldom/xmldom injected; edits confine to
the target paragraphs; accept/reject round-trips are exact. One engine
bug found: reconstruction mode stamps spurious w:rPrChange (ghost
'Format' revisions) on unchanged runs — strippable in a post-process,
properly fixable upstream.
Interactive mini-Patrick for the no-editor protocol: watches the canonical
draft, detects Word (~$) / LibreOffice (.~lock) markers, applies redlines
immediately when unlocked, parks them while open and applies on close, and
scans each save for new @patrick comments as an in-document command channel.
--sim mode fakes the lock marker and verifies the park/apply machine.
Adds the living-doc plan (measured integration surface, target
architecture, two-PR slicing, risks) and a public USPTO office action
as the real-world docx fixture for the new engine's tests.
The adapter (lib/docx/redline.ts) is the only import point for
@ansonlai/docx-redline-js and files off its measured sharp edges: it
resolves the target paragraph itself (exact + unambiguous, so the
engine's fuzzy matcher can never redline the wrong paragraph),
supersedes Patrick's own pending revisions before a re-edit (redlines
are original -> latest, never stacked), verifies the landed text before
returning, and strips the engine's ghost format revisions.

The dance (lib/docx/dance.ts) shares the draft with Word/LibreOffice by
protocol: reads never block, writes apply immediately when the draft is
closed and park while a lock marker is present, draining on a 1s tick
(comments before redlines so anchors resolve). Status exposes lock
state, parked count, @patrick comment mentions, and failed parked edits.
read_draft / edit_paragraph / add_draft_comment / read_draft_comments —
server-executed against the active draft on disk through the dance
(names single-sourced in @patrick/shared). The seven no-execute editor
tools and their client round-trip are gone; pinned-docx context
extraction now uses the adapter's own text walk instead of DocxReviewer.
New routes: draft-status (+ clear-failures), docx-text (the in-app
preview), and open (OS opener for Word). Capabilities prose teaches the
dance: save = talk to Patrick, close = let Patrick write.
A .docx tab is now a live text preview + the dance status bar (lock
state, parked-edit count, @patrick comment mentions, failed parked
edits, Open in Word) — deliberately not a Word imitation; review happens
in Word's own review pane. Deletes the editor mount (docx-viewer), the
active-editor ref registry, and the zoom pill; drops the @eigenpal deps,
the editor CSS @source, and the editor branch of onToolCall (draft tools
resolve server-side). Spikes are lint/knip-exempt like other non-app code.
Field testing found parallel tool calls losing writes: every mutation is
read-file -> transform -> write-file, and the model fires calls
concurrently, so 12 parallel comments collapsed to 3 (each read the same
base bytes; last write won). All mutations now serialize through a
per-draft promise chain — same discipline as mutateChart — with a
12-parallel-comments regression test.

The preview now shows pending redlines as real ins/del marks (run-level
extraction in the adapter, rendered as <ins>/<del>), and the panel
refreshes the moment a mutating draft tool result streams back instead
of waiting for the status poll.
Every Patrick edit is now a surgical tracked change (reject-all restores
the text exactly, byte-identical elsewhere), so the copy-on-unlock
indirection that protected originals from the old editor's whole-file
round-trip is no longer earning its clunkiness. requestUnlock now flips
the original .docx itself to editable — one explicit consent per
document — after snapshotting its pristine bytes to .patrick/backups
(never overwritten by a re-unlock). editable ≡ .docx && (createdInPatrick
|| unlocked), single-sourced in @patrick/shared isEditableDoc. Originals
still can't be renamed or deleted, and paraId backfill goes with the old
anchor-by-id tools.
@vercel

vercel Bot commented Jul 4, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
patrick-web Ready Ready Preview, Comment Jul 4, 2026 2:41pm

Adapter (redline.ts):
- strict post-apply verification at the edited position only (block
  i..i+delta when the engine splits paragraphs) — newText appearing
  elsewhere in the document can no longer vouch for a mangled apply
- refuse paragraphs carrying the ATTORNEY's pending tracked changes
  (editing through them risks absorbing their authorship)
- merge result.numberingXml so list-shaped rewrites can't reference
  numbering ids that don't exist (Word repair prompt)
- text boxes: skip w:txbxContent + mc:Fallback in walks and hide nested
  w:p from reads/edits (indices keep their engine-aligned slots) —
  box text was extracted twice and permanently unambiguous to edit
- tabs/breaks extract as separators; comment ids seed from the document
  so a restart can't mint a duplicate id

Dance:
- new ops drain older parked ops first (a stale parked edit could
  supersede a newer edit to the same paragraph)
- parked ops persist to .patrick/parked/ and survive an app restart
- engine throws inside a drain are recorded as failures instead of
  silently discarding the rest of the queue; draft writes are atomic
  (tmp + rename); mentions memoised by mtime; 7-char Word lock tier

Context/product:
- a pinned docx that gets unlocked keeps serving the PRISTINE backup as
  the pinned source (immutable, cacheable) instead of the live draft
- the manifest names other editable drafts so a second unlocked doc
  doesn't vanish from Patrick's world
- createDraft/unlock HITL handlers set the active draft synchronously
  (state + transport ref) so the auto-continued turn can't ship
  activeDraft: null; the sticky-draft effect keeps unknown-yet drafts
- the dead PUT document-bytes route is gone (it bypassed the dance)
@mhurhangee
mhurhangee merged commit 0c1cbec into main Jul 4, 2026
3 checks passed

This branch was successfully deployed

1 active deployment
Preview — e4389350 Deployed Jul 4, 2026 by vercel[bot]
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