Skip to content

CMS-241: post-accept Undo on the chat-assistant Applied banner#148

Merged
iipanda merged 10 commits into
mainfrom
feat/CMS-241-undo-applied-proposal
May 18, 2026
Merged

CMS-241: post-accept Undo on the chat-assistant Applied banner#148
iipanda merged 10 commits into
mainfrom
feat/CMS-241-undo-applied-proposal

Conversation

@iipanda
Copy link
Copy Markdown
Collaborator

@iipanda iipanda commented May 18, 2026

Summary

Wires functional Undo onto the 6-second "Applied" banner the chat assistant renders in place of the proposal card after Accept succeeds. Closes CMS-241.

Today the banner ships visual scaffolding only — the Undo button is suppressed because cosmetically clearing the accepted state without server-side rollback would lie. This PR adds the real revert paths for all five proposal kinds and turns the button on.

What lands

Spec — docs/specs/SPEC-014

  • New "Post-Accept Undo Window" section: 6-second window, per-kind revert mechanism, ⌘Z / Ctrl+Z behaviour, hover / tab-hidden / reload semantics, undo authorization, concurrent-edit conflict handling, out-of-scope clarifications.
  • Apply endpoint response schema extended with optional priorDraft: { body, frontmatter } for body/frontmatter mutating kinds.
  • New endpoint row for POST /api/v1/ai/proposals/:proposalId/undo.
  • Audit outcome enum gains undone (and an undo_failed counterpart on the failure audit path).

Server — core.ai

  • applyAiProposal now returns { document, priorDraft? }. For replace_selection / insert_block / update_frontmatter the function snapshots the pre-apply body and frontmatter so the client can replay it on undo without server-side state.
  • New applyAiProposalUndo reverses an applied proposal per kind:
    • create_document → soft-delete the newly created document
    • delete_document → restore from trash
    • edit kinds → body/frontmatter replay
    • Rejects with AI_PROPOSAL_CONFLICT on a concurrent edit inside the undo window.
  • New POST /api/v1/ai/proposals/:proposalId/undo route mounts the undo path; authorization mirrors the action being reverted (content:delete for create-doc undo, content:write otherwise).
  • Audit emission: outcome: "undone" on success, "undo_failed" with the underlying error code on failure.
  • Content store wiring threads restore through to the AI module so delete_document undo works against the database store.

Studio

  • StudioAiRouteApi.undoProposal calls the new endpoint; StudioAiApplyResult now surfaces priorDraft for body/frontmatter kinds.
  • AssistantProposal gains acceptedDocumentId, priorDraft, and postApplyDraftRevision, all captured on apply success.
  • assistant-context gains an undoProposal action, a mark-proposal-undone reducer case (strip the proposal row, append a hidden side-channel turn so the model sees the reversal), and describeUndoForAgent.
  • AppliedBanner now exposes Undo when a handler is wired AND the proposal carries the per-kind metadata. Banners register their undo callback in a panel-scoped LIFO so ⌘Z / Ctrl+Z fires the most recently still-open window.
  • AssistantPanel listens for ⌘Z / Ctrl+Z, ignores the chord unless focus is inside the panel root, and pops the top of the undo stack.
  • Drive-by fix on the assistant launcher's active-state contrast in light theme (carried-over local change; same surface).

Tests

  • applyAiProposalUndo per kind + concurrent-edit conflict + missing-priorDraft guard.
  • POST /api/v1/ai/proposals/:id/undo happy path / 400 missing body / schema-hash mismatch with undo_failed audit.
  • applyAiProposal happy-path now asserts priorDraft is present.
  • StudioAiRouteApi.undoProposal posts to the right URL with priorDraft + postApplyDraftRevision.
  • applied-undo-stack LIFO + unsubscribe.

Changeset: @mdcms/studio minor, @mdcms/shared patch.

Test plan

  • bun run check — build + typecheck pass
  • bun test packages/modules/core.ai — 43 / 43 pass (10 existing + 6 new undo unit + new route tests)
  • bun test packages/studio/src/lib/ai-route-api.test.ts ./src/lib/runtime-ui/components/assistant/applied-undo-stack.test.ts — pass
  • Prettier clean on touched files
  • No-active-sprint guardrail — warned and continued per the skill workflow
  • Manual smoke once on a dev server: accept a create_document proposal → click Undo within 6s → verify document soft-deletes and the banner dismisses
  • Manual smoke: accept a replace_selection proposal → press ⌘Z while focus is inside the panel → verify body reverts and a hidden side-channel turn appears in subsequent conversation history
  • Manual smoke: accept a proposal, edit the doc in another tab inside the window, click Undo → verify it fails with AI_PROPOSAL_CONFLICT

Pre-existing test failures (unchanged on this branch)

5 tests fail on main and continue to fail here — none touched by this PR: 4 CLI login authorize tests + 1 demo:seed test. Verified by running bun run unit on main before pushing.

Summary by CodeRabbit

Release Notes

  • New Features

    • Added post-accept undo for AI-assisted editing proposals. After accepting changes, a 6-second "Applied" banner displays an "Undo" button. Alternatively, press ⌘Z / Ctrl+Z to undo within the window. Undo is unavailable after the window expires or if concurrent edits are detected.
  • Documentation

    • Updated specification for AI-assisted editing to document the post-accept undo window and conflict handling behavior.

Review Change Stack

iipanda added 7 commits May 18, 2026 13:52
…t theme

Lime text on a near-white lime tint failed contrast when the launcher
was open. Use the foreground color for the label and a stronger lime
border so the active state stays legible while still reading as "on".
Adds the spec delta for CMS-241: a 6-second undo window that opens after
apply succeeds, the new POST /api/v1/ai/proposals/:id/undo endpoint, the
priorDraft addition to the apply response for body/frontmatter kinds, the
undone audit outcome, and the keyboard/hover/tab-hidden behaviour. No
implementation change in this commit.
Adds the server side of the post-accept undo window for AI proposals:

- `applyAiProposal` now returns `{ document, priorDraft? }`. For
  `replace_selection`, `insert_block`, and `update_frontmatter` kinds
  the function snapshots the pre-apply body and frontmatter so the
  client can replay it on undo without server-side storage.
- New `applyAiProposalUndo` reverses an applied proposal per kind:
  soft-delete for `create_document`, restore-from-trash for
  `delete_document`, and a body/frontmatter replay for the three edit
  kinds. Rejects with `AI_PROPOSAL_CONFLICT` on a concurrent edit
  inside the undo window.
- New POST `/api/v1/ai/proposals/:proposalId/undo` route mounts the
  undo path; authorization mirrors the action being reverted.
- Audit outcome enum gains `undone` and `undo_failed`, emitted from
  the new handler.
- Content store wiring threads `restore` through to the AI module so
  the `delete_document` undo path works against the database store.
Connects the existing AppliedBanner countdown to a working undo path:

- `StudioAiRouteApi.undoProposal` calls the new server endpoint and
  surfaces `priorDraft` from the apply response in `StudioAiApplyResult`.
- `AssistantProposal` gains `acceptedDocumentId`, `priorDraft`, and
  `postApplyDraftRevision` — all captured on apply success so undo
  knows which document to target and what to replay.
- `assistant-context` gets an `undoProposal` action, a
  `mark-proposal-undone` reducer case (strip the proposal row, append
  a hidden side-channel turn so the model sees the reversal), and the
  `describeUndoForAgent` helper.
- `AppliedBanner` now exposes Undo when a handler is wired AND the
  proposal carries the per-kind metadata. Banners register their undo
  callback in a panel-scoped LIFO so ⌘Z / Ctrl+Z fires the most
  recently still-open window.
- `AssistantPanel` listens for ⌘Z / Ctrl+Z, ignores the chord unless
  focus is inside the panel root, and pops the top of the undo stack.

Inline AI hook test stub gains an `undoProposal` no-op so the
StudioAiRouteApi contract is still satisfied.
- applyAiProposal now returns priorDraft for replace_selection; asserted.
- applyAiProposalUndo: create_document soft-delete, delete_document
  restore (and refuse-if-not-deleted), replace_selection replay,
  concurrent-edit conflict, missing-priorDraft guard.
- POST /api/v1/ai/proposals/:id/undo: happy path emits undone audit,
  missing proposal body returns 400, schema hash mismatch emits
  undo_failed audit.
- ai-route-api: undoProposal posts the right URL with documentId,
  priorDraft, and postApplyDraftRevision.
- applied-undo-stack: LIFO trigger order, unsubscribe removes entries.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 18, 2026

Warning

Rate limit exceeded

@iipanda has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 39 minutes and 9 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 00a5be66-c9f8-4cc6-80e5-9a787c3d8e50

📥 Commits

Reviewing files that changed from the base of the PR and between 48945ee and f7eff4f.

📒 Files selected for processing (4)
  • packages/modules/core.ai/src/server/apply.test.ts
  • packages/modules/core.ai/src/server/apply.ts
  • packages/modules/core.ai/src/server/routes.test.ts
  • packages/modules/core.ai/src/server/routes.ts
📝 Walkthrough

Walkthrough

This PR introduces an undo window for accepted AI proposals in the Studio chat assistant. After an accept succeeds, a 6-second lime banner displays an Undo button and keyboard shortcut (⌘Z / Ctrl+Z) to reverse the change. The undo endpoint applies kind-specific reversals: soft-delete for new documents, restore for deleted documents, and snapshot replay for text edits, with conflict detection when concurrent edits occur.

Changes

Post-Accept Undo Window

Layer / File(s) Summary
Backend Undo Contracts & Storage Adapter
packages/modules/core.ai/src/server/apply.ts, apps/server/src/lib/runtime-with-modules.ts
AiApplyContentStore gains optional restore method. applyAiProposal now returns { document, priorDraft? } instead of document alone, capturing body/frontmatter pre-mutation for body/frontmatter-editing kinds. New types (AiApplyResult, AiApplyPriorDraft, AiUndoInput, AiUndoResult) define undo contracts.
applyAiProposalUndo Core Logic
packages/modules/core.ai/src/server/apply.ts, apply.test.ts
applyAiProposalUndo implements kind-specific undo: soft-deletes for create_document, restores + verifies deletion for delete_document, and replays snapshots with draft-revision conflict detection for edits. Tests cover all kinds, idempotency, and revision mismatch.
Undo Endpoint & Route Integration
packages/modules/core.ai/src/server/routes.ts, routes.test.ts
New POST /api/v1/ai/proposals/:proposalId/undo endpoint validates priorDraft shape, checks schema hash, authorizes by kind, calls applyAiProposalUndo, and emits undone/undo_failed audits. Apply endpoint refactored to return priorDraft in response. buildLifecycleAudit supports documentId override for accurate audit trails.
Core AI Module Re-exports
packages/modules/core.ai/src/index.ts
Public API extended to export applyAiProposalUndo and undo-related types.
Client Route API
packages/studio/src/lib/ai-route-api.ts, ai-route-api.test.ts
StudioAiRouteApi gains undoProposal method; StudioAiApplyResult includes optional priorDraft. Client implementation POSTs to undo endpoint with CSRF token and payloads.
Undo Handler Stack
packages/studio/src/lib/runtime-ui/components/assistant/applied-undo-stack.ts, applied-undo-stack.test.ts
In-memory LIFO stack for undo handlers. pushAppliedUndoHandler registers handlers; triggerTopAppliedUndo fires the topmost handler and returns whether one existed, enabling global keyboard listener to prevent default.
Proposal Type Extensions
packages/studio/src/lib/runtime-ui/components/assistant/assistant-types.ts
AssistantProposal base shape gains optional acceptedDocumentId, priorDraft, and postApplyDraftRevision for undo metadata.
Proposal Accept/Undo Lifecycle
packages/studio/src/lib/runtime-ui/components/assistant/assistant-context.tsx
acceptProposal now captures applyResult and dispatches undo metadata via mark-proposal-accepted. New undoProposal handler validates metadata, calls server undo endpoint, then dispatches mark-proposal-undone to remove proposal and append hidden undo turn. describeUndoForAgent generates hidden undo messages.
Applied Banner & Countdown UX
packages/studio/src/lib/runtime-ui/components/assistant/proposal-card.tsx
AppliedBanner manages 6-second countdown, registers handlers via pushAppliedUndoHandler, disables Undo button while pending. AcceptedView computes canUndo from handler + metadata, tracks pending and errorMessage state, transitions to quiet log after expiration or error. Inline error display with special-case for AI_PROPOSAL_CONFLICT.
Panel Keyboard Shortcut & Threading
packages/studio/src/lib/runtime-ui/components/assistant/assistant-panel.tsx
Panel owns panelRootRef and listens for ⌘Z/Ctrl+Z scoped to panel focus. Threads onUndo handler through AssistantBubbleProposalCard / TurnGroupAcceptedView. When shortcut fires, calls triggerTopAppliedUndo() and prevents default.
Specification & Documentation
docs/specs/SPEC-014-ai-assisted-studio-editing.md, .changeset/swift-seas-press.md
Spec updated with post-accept undo window definition, endpoint contracts, kind-specific behavior, audit outcomes (undone, undo_failed), conflict handling, and regression coverage. Changeset documents feature, API changes, and audit enum extensions.
Minor Styling & Test Stubs
packages/studio/src/lib/runtime-ui/components/assistant/assistant-launcher.tsx, use-inline-ai-transform.test.ts
CSS updated for launcher button open state. Test fixture adds undoProposal stub to fakeApi.

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly Related PRs

  • mdcms-ai/mdcms#139: Establishes the foundational proposal apply lifecycle and audit scaffolding that the undo feature extends with new return types, endpoint routes, and outcome states.
  • mdcms-ai/mdcms#145: Modifies the same proposal-acceptance reducer path in assistant-context.tsx; main PR layers undo metadata/payloads on top of accepted proposal state management.

🐰 A rabbit hops through time with glee,
Undoing proposals for all to see,
Ctrl+Z brings back what was before,
Six seconds to change your mind—undo to the core! 🎯↩️

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 44.44% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title clearly and concisely summarizes the main feature: implementing an Undo capability on the chat-assistant Applied banner after accepting AI proposals.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/CMS-241-undo-applied-proposal

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Four review findings from PR #148:

- Require postApplyDraftRevision on the undo route for body/frontmatter
  kinds — the apply.ts guard only fired when the field was supplied,
  so a missing field would silently overwrite a concurrent edit.
  Returns INVALID_INPUT at the route boundary.
- Thread the operated documentId into both undone and undo_failed
  audit records via a new optional override on buildLifecycleAudit.
  Without this, create_document undo records had no documentId
  (proposal.documentId is undefined for that kind by schema).
- TurnGroup multi-proposal turns now thread onUndo into every
  accepted child so each one opens its own independent undo window
  with ⌘Z support, matching the single-card path.
- Pessimistic banner: AcceptedView awaits the undo round-trip before
  dismissing. Pending state pauses the countdown and disables the
  button; failures keep the banner mounted with an inline error
  (no separate chat error turn) per SPEC-014. undoProposal context
  action now returns Promise<void> and rejects with the underlying
  error so the banner can render it.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/modules/core.ai/src/server/apply.ts`:
- Around line 480-509: When handling body/frontmatter undos in
applyAiProposalUndo, enforce that input.postApplyDraftRevision is provided:
after confirming input.priorDraft (and before fetching existing), add a guard
that if proposal.kind === "body" || proposal.kind === "frontmatter" and typeof
input.postApplyDraftRevision !== "number" then throw aiOutputInvalid (include
proposalId and documentId) so the core logic refuses to replay undos without an
explicit postApplyDraftRevision.

In `@packages/modules/core.ai/src/server/routes.ts`:
- Around line 949-975: The handler currently trusts the request body documentId
separately from proposal.documentId; add a validation after parsedProposal is
set and before authorization (i.e., before calling options.authorize) that for
any proposal.kind !== "create_document" the request body documentId (the
variable used to identify the target doc) must strictly equal
proposal.documentId, and if not throw invalidInput (or the same error shape used
elsewhere) indicating the mismatched documentId and include proposal.kind;
ensure this check lives alongside the existing postApplyDraftRevision validation
so it prevents undo operations against the wrong document for edit/delete kinds.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 458a5062-4abb-451a-81af-54a1d2a3fcd5

📥 Commits

Reviewing files that changed from the base of the PR and between c45cb8c and 48945ee.

📒 Files selected for processing (19)
  • .changeset/swift-seas-press.md
  • apps/server/src/lib/runtime-with-modules.ts
  • docs/specs/SPEC-014-ai-assisted-studio-editing.md
  • packages/modules/core.ai/src/index.ts
  • packages/modules/core.ai/src/server/apply.test.ts
  • packages/modules/core.ai/src/server/apply.ts
  • packages/modules/core.ai/src/server/audit.ts
  • packages/modules/core.ai/src/server/routes.test.ts
  • packages/modules/core.ai/src/server/routes.ts
  • packages/studio/src/lib/ai-route-api.test.ts
  • packages/studio/src/lib/ai-route-api.ts
  • packages/studio/src/lib/runtime-ui/components/assistant/applied-undo-stack.test.ts
  • packages/studio/src/lib/runtime-ui/components/assistant/applied-undo-stack.ts
  • packages/studio/src/lib/runtime-ui/components/assistant/assistant-context.tsx
  • packages/studio/src/lib/runtime-ui/components/assistant/assistant-launcher.tsx
  • packages/studio/src/lib/runtime-ui/components/assistant/assistant-panel.tsx
  • packages/studio/src/lib/runtime-ui/components/assistant/assistant-types.ts
  • packages/studio/src/lib/runtime-ui/components/assistant/proposal-card.tsx
  • packages/studio/src/lib/runtime-ui/hooks/use-inline-ai-transform.test.ts

Comment thread packages/modules/core.ai/src/server/apply.ts
Comment thread packages/modules/core.ai/src/server/routes.ts
iipanda added 2 commits May 18, 2026 18:12
- applyAiProposalUndo now refuses body/frontmatter undo when
  postApplyDraftRevision is missing, not just when it disagrees with
  the live revision. The route already enforces this, but direct
  callers of the exported function were able to skip the guard and
  silently overwrite concurrent edits.
- The undo route now rejects requests where the body documentId
  diverges from the proposal's target document (every kind except
  create_document, which has no documentId by schema). Without this
  a caller could replay a captured priorDraft onto an unrelated
  document they happen to have write access to.

Tests added for both guards.
@iipanda iipanda merged commit 02db804 into main May 18, 2026
5 checks passed
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