Skip to content

Commit 7dee4a5

Browse files
authored
feat(document-api): comment reopen lifecycle inverse (SD-2789) (#2987)
* feat(document-api): comment reopen lifecycle inverse (SD-2789) Implements the missing inverse of `comments.patch({ status: 'resolved' })` so resolve <-> reopen round-trips both at the engine level and across DOCX. Surfaced on the public Document API as `comments.patch({ commentId, status: 'active' })`. Resolve removes the live `comment` mark and inserts `commentRangeStart` / `commentRangeEnd` anchor nodes around the same range. Reopen mirrors that: matches anchor nodes by `w:id`, pairs them in document order, re-inserts the comment mark with `(commentId, importedId, internal)` attrs, and deletes the anchor nodes. The `internal` flag falls back to the value stamped on `commentRangeStart` when no override is provided so import-resolved comments (with `w15:done="1"`) reopen without losing the flag; the plan-engine handler reads the entity store value when present and forwards it as the override path. Engine - `reopenCommentById` in `comments-helpers.js` — symmetric inverse of `resolveCommentById`. Maps positions through `tr.mapping` so mark inserts and node deletes stay in sync; processes deletes in descending order to avoid position drift. - `reopenComment` editor command in `comments-plugin.js`. Document API - `ReopenCommentInput` type alongside `ResolveCommentInput`. - `CommentsPatchInput.status` widened to `'resolved' | 'active'`. - `validatePatchCommentInput` accepts both values. - `executeCommentsPatch` routes `status: 'active'` to `adapter.reopen`. - `CommentsAdapter.reopen` interface method added. - Public re-export from the `@superdoc/document-api` barrel. Adapter - `reopenCommentHandler` in `comments-wrappers.ts` mirrors `resolveCommentHandler`: short-circuits to NO_OP when neither the entity store nor the doc anchors indicate a resolved state, flips `isDone: false` and clears `resolvedTime` on success. - Wired into `createCommentsWrapper`. - `'reopenComment'` added to the `'comments.patch'` capability list so feature detection still produces a single value for the operation. Tests - Engine-level: reopen restores mark + removes anchors; honors `internal` from anchor; honors explicit `internal` override; no-op when not resolved. - Doc-API: validator accepts `'active'`, rejects others; routes to `adapter.reopen` not `adapter.resolve`. - Capability registry test stub gets `reopenComment`. Generated artifacts - SDK contract regenerated via `pnpm run generate:all`. Only formatter normalization landed in `intent-dispatch.ts` — operation count unchanged. Out of scope - Audit history of resolve/reopen events. - Reopening a deleted comment (delete is irreversible). - Threaded reply resolution semantics (root only). - Lab cleanup in `examples/headless/dropin-assessment` — will land in a follow-up PR alongside the SD-2790 ui.comments consumer migration. * fix(document-api): widen comments.patch.status JSON schema to active (PR #2987 review) The TypeScript type for `CommentsPatchInput.status` was widened to `'resolved' | 'active'` in the prior commit, but the contract JSON schema in `contract/schemas.ts` still enumerated only `['resolved']`. After `generate:all`, the SDK / CLI / MCP / tool-catalog clients that hydrate from the contract schema would have rejected `status: 'active'`, leaving reopen reachable only from direct TypeScript callers — not the documented contract surface. Widens the input schema's enum to `['resolved', 'active']` and extends the description to call out the lifecycle-inverse semantics so contract-driven generators (Mintlify reference, OpenAI tool schema, Python SDK) all see the same shape as the typed surface. Output schemas (`commentInfoSchema`, `commentDomainItemSchema`, the listing fragment) keep `['open', 'resolved']` — those describe *states a comment can be in*, not *transitions a caller can request*. Two intentionally different vocabularies: input is imperative (`'active'` = "reopen this"), output is the resulting state (`'open'`). Regenerated artifacts via `pnpm run generate:all`: - `apps/docs/document-api/reference/comments/patch.mdx` — reference doc now lists both enum values + describes reopen. - `_generated-manifest.json` — manifest hash refresh. * fix(super-editor): add reopenComment to CommentCommands typings (PR #2987 review)
1 parent f60241c commit 7dee4a5

13 files changed

Lines changed: 421 additions & 15 deletions

File tree

apps/docs/document-api/reference/_generated-manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1031,5 +1031,5 @@
10311031
}
10321032
],
10331033
"marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}",
1034-
"sourceHash": "cdb0b02e84f6eb7f4db962c177d082e0f89ec48517abc775736a3d17e4da9ba8"
1034+
"sourceHash": "fa717df8cc013f9703b118596afe4cbbf655fe73f2e4e856588844110998ee2e"
10351035
}

apps/docs/document-api/reference/comments/patch.mdx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ Returns a Receipt confirming the comment was updated; reports NO_OP if no fields
2828
| --- | --- | --- | --- |
2929
| `commentId` | string | yes | |
3030
| `isInternal` | boolean | no | |
31-
| `status` | enum | no | `"resolved"` |
31+
| `status` | enum | no | `"resolved"`, `"active"` |
3232
| `target` | TextAddress | no | TextAddress |
3333
| `target.blockId` | string | no | |
3434
| `target.kind` | `"text"` | no | Constant: `"text"` |
@@ -124,9 +124,10 @@ Returns a Receipt confirming the comment was updated; reports NO_OP if no fields
124124
"type": "boolean"
125125
},
126126
"status": {
127-
"description": "Set comment status. Use 'resolved' to mark as resolved.",
127+
"description": "Set comment status. Use 'resolved' to resolve a comment, or 'active' to reopen a previously resolved comment (lifecycle inverse).",
128128
"enum": [
129-
"resolved"
129+
"resolved",
130+
"active"
130131
]
131132
},
132133
"target": {

packages/document-api/src/comments/comments.test.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const stubAdapter = () =>
1414
reply: mock(() => ({ success: true })),
1515
move: mock(() => ({ success: true })),
1616
resolve: mock(() => ({ success: true })),
17+
reopen: mock(() => ({ success: true })),
1718
remove: mock(() => ({ success: true })),
1819
setInternal: mock(() => ({ success: true })),
1920
setActive: mock(() => ({ success: true })),
@@ -60,7 +61,7 @@ describe('executeCommentsPatch validation', () => {
6061

6162
it('rejects invalid status', () => {
6263
expect(() => executeCommentsPatch(stubAdapter(), { commentId: 'c1', status: 'open' } as any)).toThrow(
63-
/must be "resolved"/,
64+
/must be "resolved" or "active"/,
6465
);
6566
});
6667

@@ -75,6 +76,20 @@ describe('executeCommentsPatch validation', () => {
7576
executeCommentsPatch(adapter, { commentId: 'c1', isInternal: true });
7677
expect(adapter.setInternal).toHaveBeenCalled();
7778
});
79+
80+
it('routes status:"resolved" to adapter.resolve', () => {
81+
const adapter = stubAdapter();
82+
executeCommentsPatch(adapter, { commentId: 'c1', status: 'resolved' });
83+
expect(adapter.resolve).toHaveBeenCalledWith({ commentId: 'c1' }, undefined);
84+
expect(adapter.reopen).not.toHaveBeenCalled();
85+
});
86+
87+
it('routes status:"active" to adapter.reopen (lifecycle inverse of resolve)', () => {
88+
const adapter = stubAdapter();
89+
executeCommentsPatch(adapter, { commentId: 'c1', status: 'active' });
90+
expect(adapter.reopen).toHaveBeenCalledWith({ commentId: 'c1' }, undefined);
91+
expect(adapter.resolve).not.toHaveBeenCalled();
92+
});
7893
});
7994

8095
describe('executeCommentsDelete validation', () => {

packages/document-api/src/comments/comments.ts

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,14 @@ export interface ResolveCommentInput {
4444
commentId: string;
4545
}
4646

47+
/**
48+
* Input for reopening a previously-resolved comment. Accepted as the
49+
* `status: 'active'` branch of `comments.patch`.
50+
*/
51+
export interface ReopenCommentInput {
52+
commentId: string;
53+
}
54+
4755
export interface RemoveCommentInput {
4856
commentId: string;
4957
}
@@ -104,8 +112,12 @@ export interface CommentsPatchInput {
104112
text?: string;
105113
/** New anchor range (routes to move). */
106114
target?: TextAddress;
107-
/** Set status to 'resolved' (routes to resolve). */
108-
status?: 'resolved';
115+
/**
116+
* Lifecycle transition. `'resolved'` routes to resolve, `'active'`
117+
* routes to reopen — symmetric inverse that removes the resolve
118+
* anchors and restores the live comment mark.
119+
*/
120+
status?: 'resolved' | 'active';
109121
/** Set the internal/private flag (routes to setInternal). */
110122
isInternal?: boolean;
111123
}
@@ -132,6 +144,14 @@ export interface CommentsAdapter {
132144
move(input: MoveCommentInput, options?: RevisionGuardOptions): Receipt;
133145
/** Resolve an open comment. */
134146
resolve(input: ResolveCommentInput, options?: RevisionGuardOptions): Receipt;
147+
/**
148+
* Reopen a previously-resolved comment. Symmetric inverse of
149+
* {@link CommentsAdapter.resolve}: removes the
150+
* `commentRangeStart` / `commentRangeEnd` anchor nodes inserted at
151+
* resolve time and restores the live `comment` mark across the
152+
* original range so subsequent operations see the comment as active.
153+
*/
154+
reopen(input: ReopenCommentInput, options?: RevisionGuardOptions): Receipt;
135155
/** Remove a comment from the document. */
136156
remove(input: RemoveCommentInput, options?: RevisionGuardOptions): Receipt;
137157
/** Set the internal/private flag on a comment. */
@@ -268,11 +288,15 @@ function validatePatchCommentInput(input: unknown): asserts input is CommentsPat
268288
});
269289
}
270290

271-
if (status !== undefined && status !== 'resolved') {
272-
throw new DocumentApiValidationError('INVALID_INPUT', `status must be "resolved", got "${String(status)}".`, {
273-
field: 'status',
274-
value: status,
275-
});
291+
if (status !== undefined && status !== 'resolved' && status !== 'active') {
292+
throw new DocumentApiValidationError(
293+
'INVALID_INPUT',
294+
`status must be "resolved" or "active", got "${String(status)}".`,
295+
{
296+
field: 'status',
297+
value: status,
298+
},
299+
);
276300
}
277301

278302
if (isInternal !== undefined && typeof isInternal !== 'boolean') {
@@ -341,6 +365,9 @@ export function executeCommentsPatch(
341365
if (input.status === 'resolved') {
342366
return adapter.resolve({ commentId: input.commentId }, options);
343367
}
368+
if (input.status === 'active') {
369+
return adapter.reopen({ commentId: input.commentId }, options);
370+
}
344371
if (input.isInternal !== undefined) {
345372
return adapter.setInternal({ commentId: input.commentId, isInternal: input.isInternal }, options);
346373
}

packages/document-api/src/contract/schemas.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4812,7 +4812,11 @@ const operationSchemas: Record<OperationId, OperationSchemaSet> = {
48124812
commentId: { type: 'string' },
48134813
text: { type: 'string', description: 'Updated comment text.' },
48144814
target: textAddressSchema,
4815-
status: { enum: ['resolved'], description: "Set comment status. Use 'resolved' to mark as resolved." },
4815+
status: {
4816+
enum: ['resolved', 'active'],
4817+
description:
4818+
"Set comment status. Use 'resolved' to resolve a comment, or 'active' to reopen a previously resolved comment (lifecycle inverse).",
4819+
},
48164820
isInternal: {
48174821
type: 'boolean',
48184822
description: 'When true, marks the comment as internal (hidden from external collaborators).',

packages/document-api/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1416,6 +1416,7 @@ export type {
14161416
ReplyToCommentInput,
14171417
MoveCommentInput,
14181418
ResolveCommentInput,
1419+
ReopenCommentInput,
14191420
RemoveCommentInput,
14201421
SetCommentInternalInput,
14211422
GoToCommentInput,

packages/super-editor/src/editors/v1/document-api-adapters/capabilities-adapter.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ function makeEditor(overrides: Partial<Editor> = {}): Editor {
1515
addCommentReply: vi.fn(() => true),
1616
moveComment: vi.fn(() => true),
1717
resolveComment: vi.fn(() => true),
18+
reopenComment: vi.fn(() => true),
1819
removeComment: vi.fn(() => true),
1920
setCommentInternal: vi.fn(() => true),
2021
setActiveComment: vi.fn(() => true),

packages/super-editor/src/editors/v1/document-api-adapters/capabilities-adapter.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ const REQUIRED_COMMANDS: Partial<Record<OperationId, readonly EditorCommandName[
5959
'lists.clearLevelOverrides': [],
6060
'blocks.delete': ['deleteBlockNodeById'],
6161
'comments.create': ['addComment', 'setTextSelection', 'addCommentReply'],
62-
'comments.patch': ['editComment', 'moveComment', 'resolveComment', 'setCommentInternal'],
62+
'comments.patch': ['editComment', 'moveComment', 'resolveComment', 'reopenComment', 'setCommentInternal'],
6363
'comments.delete': ['removeComment'],
6464
'trackChanges.decide': [
6565
'acceptTrackedChangeById',

packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.ts

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* engine's revision management and execution path.
44
*
55
* Read operations (list, get, goTo) are pure queries or non-mutating navigation.
6-
* Mutating operations (add, edit, reply, move, resolve, remove, setInternal, setActive)
6+
* Mutating operations (add, edit, reply, move, resolve, reopen, remove, setInternal, setActive)
77
* delegate to editor commands with plan-engine revision tracking.
88
*/
99

@@ -20,6 +20,7 @@ import type {
2020
MoveCommentInput,
2121
Receipt,
2222
RemoveCommentInput,
23+
ReopenCommentInput,
2324
ReplyToCommentInput,
2425
ResolveCommentInput,
2526
RevisionGuardOptions,
@@ -756,6 +757,68 @@ function resolveCommentHandler(editor: Editor, input: ResolveCommentInput, optio
756757
return { success: true, updated: [toCommentAddress(identity.commentId)] };
757758
}
758759

760+
function reopenCommentHandler(editor: Editor, input: ReopenCommentInput, options?: RevisionGuardOptions): Receipt {
761+
const reopenComment = requireEditorCommand(editor.commands?.reopenComment, 'comments.patch (reopenComment)');
762+
763+
const store = getCommentEntityStore(editor);
764+
const identity = resolveCommentIdentity(editor, input.commentId);
765+
const existing = findCommentEntity(store, identity.commentId);
766+
// Idempotent on the no-op path: reopening an already-active comment
767+
// (no anchor nodes in the doc, entity store doesn't show resolved)
768+
// returns NO_OP rather than running a command that would fail
769+
// silently.
770+
const isAnchored = identity.anchors.length > 0;
771+
const isResolvedInStore = existing ? isCommentResolved(existing) : false;
772+
const isResolvedInDoc = isAnchored && identity.anchors.every((a) => a.status === 'resolved');
773+
if (!isResolvedInStore && !isResolvedInDoc) {
774+
return {
775+
success: false,
776+
failure: { code: 'NO_OP', message: 'Comment is already active.' },
777+
};
778+
}
779+
780+
// Recover the original `internal` flag from the entity store when
781+
// present; the engine helper falls back to the value stamped on
782+
// `commentRangeStart` when this is undefined, so a runtime-resolved
783+
// comment with no entity record still round-trips correctly.
784+
const storedInternal = (existing as { isInternal?: unknown } | undefined)?.isInternal;
785+
const internalOverride = typeof storedInternal === 'boolean' ? storedInternal : undefined;
786+
787+
const receipt = executeDomainCommand(
788+
editor,
789+
() => {
790+
const didReopen = reopenComment({
791+
commentId: identity.commentId,
792+
importedId: identity.importedId,
793+
internal: internalOverride,
794+
});
795+
if (didReopen) {
796+
// Clear the resolved markers in the entity store so subsequent
797+
// `comments.list()` reflects the reopen. `resolvedTime` is
798+
// dropped explicitly because `upsertCommentEntity` merges
799+
// partials and would otherwise leave the prior timestamp in
800+
// place.
801+
upsertCommentEntity(store, identity.commentId, {
802+
importedId: identity.importedId,
803+
isDone: false,
804+
resolvedTime: null,
805+
});
806+
}
807+
return Boolean(didReopen);
808+
},
809+
{ expectedRevision: options?.expectedRevision },
810+
);
811+
812+
if (receipt.steps[0]?.effect !== 'changed') {
813+
return {
814+
success: false,
815+
failure: { code: 'NO_OP', message: 'Comment reopen produced no change.' },
816+
};
817+
}
818+
819+
return { success: true, updated: [toCommentAddress(identity.commentId)] };
820+
}
821+
759822
function removeCommentHandler(editor: Editor, input: RemoveCommentInput, options?: RevisionGuardOptions): Receipt {
760823
const removeComment = requireEditorCommand(editor.commands?.removeComment, 'comments.remove (removeComment)');
761824

@@ -986,6 +1049,7 @@ export function createCommentsWrapper(editor: Editor): CommentsAdapter {
9861049
move: (input: MoveCommentInput, options?: RevisionGuardOptions) => moveCommentHandler(editor, input, options),
9871050
resolve: (input: ResolveCommentInput, options?: RevisionGuardOptions) =>
9881051
resolveCommentHandler(editor, input, options),
1052+
reopen: (input: ReopenCommentInput, options?: RevisionGuardOptions) => reopenCommentHandler(editor, input, options),
9891053
remove: (input: RemoveCommentInput, options?: RevisionGuardOptions) => removeCommentHandler(editor, input, options),
9901054
setInternal: (input: SetCommentInternalInput, options?: RevisionGuardOptions) =>
9911055
setCommentInternalHandler(editor, input, options),

0 commit comments

Comments
 (0)