Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1031,5 +1031,5 @@
}
],
"marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}",
"sourceHash": "cdb0b02e84f6eb7f4db962c177d082e0f89ec48517abc775736a3d17e4da9ba8"
"sourceHash": "fa717df8cc013f9703b118596afe4cbbf655fe73f2e4e856588844110998ee2e"
}
7 changes: 4 additions & 3 deletions apps/docs/document-api/reference/comments/patch.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Returns a Receipt confirming the comment was updated; reports NO_OP if no fields
| --- | --- | --- | --- |
| `commentId` | string | yes | |
| `isInternal` | boolean | no | |
| `status` | enum | no | `"resolved"` |
| `status` | enum | no | `"resolved"`, `"active"` |
| `target` | TextAddress | no | TextAddress |
| `target.blockId` | string | no | |
| `target.kind` | `"text"` | no | Constant: `"text"` |
Expand Down Expand Up @@ -124,9 +124,10 @@ Returns a Receipt confirming the comment was updated; reports NO_OP if no fields
"type": "boolean"
},
"status": {
"description": "Set comment status. Use 'resolved' to mark as resolved.",
"description": "Set comment status. Use 'resolved' to resolve a comment, or 'active' to reopen a previously resolved comment (lifecycle inverse).",
"enum": [
"resolved"
"resolved",
"active"
]
},
"target": {
Expand Down
17 changes: 16 additions & 1 deletion packages/document-api/src/comments/comments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const stubAdapter = () =>
reply: mock(() => ({ success: true })),
move: mock(() => ({ success: true })),
resolve: mock(() => ({ success: true })),
reopen: mock(() => ({ success: true })),
remove: mock(() => ({ success: true })),
setInternal: mock(() => ({ success: true })),
setActive: mock(() => ({ success: true })),
Expand Down Expand Up @@ -60,7 +61,7 @@ describe('executeCommentsPatch validation', () => {

it('rejects invalid status', () => {
expect(() => executeCommentsPatch(stubAdapter(), { commentId: 'c1', status: 'open' } as any)).toThrow(
/must be "resolved"/,
/must be "resolved" or "active"/,
);
});

Expand All @@ -75,6 +76,20 @@ describe('executeCommentsPatch validation', () => {
executeCommentsPatch(adapter, { commentId: 'c1', isInternal: true });
expect(adapter.setInternal).toHaveBeenCalled();
});

it('routes status:"resolved" to adapter.resolve', () => {
const adapter = stubAdapter();
executeCommentsPatch(adapter, { commentId: 'c1', status: 'resolved' });
expect(adapter.resolve).toHaveBeenCalledWith({ commentId: 'c1' }, undefined);
expect(adapter.reopen).not.toHaveBeenCalled();
});

it('routes status:"active" to adapter.reopen (lifecycle inverse of resolve)', () => {
const adapter = stubAdapter();
executeCommentsPatch(adapter, { commentId: 'c1', status: 'active' });
expect(adapter.reopen).toHaveBeenCalledWith({ commentId: 'c1' }, undefined);
expect(adapter.resolve).not.toHaveBeenCalled();
});
});

describe('executeCommentsDelete validation', () => {
Expand Down
41 changes: 34 additions & 7 deletions packages/document-api/src/comments/comments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ export interface ResolveCommentInput {
commentId: string;
}

/**
* Input for reopening a previously-resolved comment. Accepted as the
* `status: 'active'` branch of `comments.patch`.
*/
export interface ReopenCommentInput {
commentId: string;
}

export interface RemoveCommentInput {
commentId: string;
}
Expand Down Expand Up @@ -104,8 +112,12 @@ export interface CommentsPatchInput {
text?: string;
/** New anchor range (routes to move). */
target?: TextAddress;
/** Set status to 'resolved' (routes to resolve). */
status?: 'resolved';
/**
* Lifecycle transition. `'resolved'` routes to resolve, `'active'`
* routes to reopen — symmetric inverse that removes the resolve
* anchors and restores the live comment mark.
*/
status?: 'resolved' | 'active';
/** Set the internal/private flag (routes to setInternal). */
isInternal?: boolean;
}
Expand All @@ -132,6 +144,14 @@ export interface CommentsAdapter {
move(input: MoveCommentInput, options?: RevisionGuardOptions): Receipt;
/** Resolve an open comment. */
resolve(input: ResolveCommentInput, options?: RevisionGuardOptions): Receipt;
/**
* Reopen a previously-resolved comment. Symmetric inverse of
* {@link CommentsAdapter.resolve}: removes the
* `commentRangeStart` / `commentRangeEnd` anchor nodes inserted at
* resolve time and restores the live `comment` mark across the
* original range so subsequent operations see the comment as active.
*/
reopen(input: ReopenCommentInput, options?: RevisionGuardOptions): Receipt;
/** Remove a comment from the document. */
remove(input: RemoveCommentInput, options?: RevisionGuardOptions): Receipt;
/** Set the internal/private flag on a comment. */
Expand Down Expand Up @@ -268,11 +288,15 @@ function validatePatchCommentInput(input: unknown): asserts input is CommentsPat
});
}

if (status !== undefined && status !== 'resolved') {
throw new DocumentApiValidationError('INVALID_INPUT', `status must be "resolved", got "${String(status)}".`, {
field: 'status',
value: status,
});
if (status !== undefined && status !== 'resolved' && status !== 'active') {
throw new DocumentApiValidationError(
'INVALID_INPUT',
`status must be "resolved" or "active", got "${String(status)}".`,
{
field: 'status',
value: status,
},
);
}

if (isInternal !== undefined && typeof isInternal !== 'boolean') {
Expand Down Expand Up @@ -341,6 +365,9 @@ export function executeCommentsPatch(
if (input.status === 'resolved') {
return adapter.resolve({ commentId: input.commentId }, options);
}
if (input.status === 'active') {
return adapter.reopen({ commentId: input.commentId }, options);
}
if (input.isInternal !== undefined) {
return adapter.setInternal({ commentId: input.commentId, isInternal: input.isInternal }, options);
}
Expand Down
6 changes: 5 additions & 1 deletion packages/document-api/src/contract/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -611,7 +611,7 @@
const trackedChangeAddressSchema = ref('TrackedChangeAddress');
const entityAddressSchema = ref('EntityAddress');
const selectionTargetSchema = ref('SelectionTarget');
const targetLocatorSchema = ref('TargetLocator');

Check warning on line 614 in packages/document-api/src/contract/schemas.ts

View workflow job for this annotation

GitHub Actions / build

'targetLocatorSchema' is assigned a value but never used. Allowed unused vars must match /^_/u
const deleteBehaviorSchema = ref('DeleteBehavior');
const resolvedHandleSchema = ref('ResolvedHandle');
const pageInfoSchema = ref('PageInfo');
Expand Down Expand Up @@ -886,7 +886,7 @@
text: { type: 'string' },
});

const nodeInfoSchema: JsonSchema = {

Check warning on line 889 in packages/document-api/src/contract/schemas.ts

View workflow job for this annotation

GitHub Actions / build

'nodeInfoSchema' is assigned a value but never used. Allowed unused vars must match /^_/u
type: 'object',
required: ['nodeType', 'kind'],
properties: {
Expand All @@ -902,7 +902,7 @@
additionalProperties: false,
};

const matchContextSchema = objectSchema(

Check warning on line 905 in packages/document-api/src/contract/schemas.ts

View workflow job for this annotation

GitHub Actions / build

'matchContextSchema' is assigned a value but never used. Allowed unused vars must match /^_/u
{
address: nodeAddressSchema,
snippet: { type: 'string' },
Expand All @@ -913,7 +913,7 @@
['address', 'snippet', 'highlightRange'],
);

const unknownNodeDiagnosticSchema = objectSchema(

Check warning on line 916 in packages/document-api/src/contract/schemas.ts

View workflow job for this annotation

GitHub Actions / build

'unknownNodeDiagnosticSchema' is assigned a value but never used. Allowed unused vars must match /^_/u
{
message: { type: 'string' },
address: nodeAddressSchema,
Expand Down Expand Up @@ -4812,7 +4812,11 @@
commentId: { type: 'string' },
text: { type: 'string', description: 'Updated comment text.' },
target: textAddressSchema,
status: { enum: ['resolved'], description: "Set comment status. Use 'resolved' to mark as resolved." },
status: {
enum: ['resolved', 'active'],
description:
"Set comment status. Use 'resolved' to resolve a comment, or 'active' to reopen a previously resolved comment (lifecycle inverse).",
},
isInternal: {
type: 'boolean',
description: 'When true, marks the comment as internal (hidden from external collaborators).',
Expand Down
1 change: 1 addition & 0 deletions packages/document-api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1416,6 +1416,7 @@ export type {
ReplyToCommentInput,
MoveCommentInput,
ResolveCommentInput,
ReopenCommentInput,
RemoveCommentInput,
SetCommentInternalInput,
GoToCommentInput,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ function makeEditor(overrides: Partial<Editor> = {}): Editor {
addCommentReply: vi.fn(() => true),
moveComment: vi.fn(() => true),
resolveComment: vi.fn(() => true),
reopenComment: vi.fn(() => true),
removeComment: vi.fn(() => true),
setCommentInternal: vi.fn(() => true),
setActiveComment: vi.fn(() => true),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ const REQUIRED_COMMANDS: Partial<Record<OperationId, readonly EditorCommandName[
'lists.clearLevelOverrides': [],
'blocks.delete': ['deleteBlockNodeById'],
'comments.create': ['addComment', 'setTextSelection', 'addCommentReply'],
'comments.patch': ['editComment', 'moveComment', 'resolveComment', 'setCommentInternal'],
'comments.patch': ['editComment', 'moveComment', 'resolveComment', 'reopenComment', 'setCommentInternal'],
'comments.delete': ['removeComment'],
'trackChanges.decide': [
'acceptTrackedChangeById',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* engine's revision management and execution path.
*
* Read operations (list, get, goTo) are pure queries or non-mutating navigation.
* Mutating operations (add, edit, reply, move, resolve, remove, setInternal, setActive)
* Mutating operations (add, edit, reply, move, resolve, reopen, remove, setInternal, setActive)
* delegate to editor commands with plan-engine revision tracking.
*/

Expand All @@ -20,6 +20,7 @@ import type {
MoveCommentInput,
Receipt,
RemoveCommentInput,
ReopenCommentInput,
ReplyToCommentInput,
ResolveCommentInput,
RevisionGuardOptions,
Expand Down Expand Up @@ -756,6 +757,68 @@ function resolveCommentHandler(editor: Editor, input: ResolveCommentInput, optio
return { success: true, updated: [toCommentAddress(identity.commentId)] };
}

function reopenCommentHandler(editor: Editor, input: ReopenCommentInput, options?: RevisionGuardOptions): Receipt {
const reopenComment = requireEditorCommand(editor.commands?.reopenComment, 'comments.patch (reopenComment)');

const store = getCommentEntityStore(editor);
const identity = resolveCommentIdentity(editor, input.commentId);
const existing = findCommentEntity(store, identity.commentId);
// Idempotent on the no-op path: reopening an already-active comment
// (no anchor nodes in the doc, entity store doesn't show resolved)
// returns NO_OP rather than running a command that would fail
// silently.
const isAnchored = identity.anchors.length > 0;
const isResolvedInStore = existing ? isCommentResolved(existing) : false;
const isResolvedInDoc = isAnchored && identity.anchors.every((a) => a.status === 'resolved');
if (!isResolvedInStore && !isResolvedInDoc) {
return {
success: false,
failure: { code: 'NO_OP', message: 'Comment is already active.' },
};
}

// Recover the original `internal` flag from the entity store when
// present; the engine helper falls back to the value stamped on
// `commentRangeStart` when this is undefined, so a runtime-resolved
// comment with no entity record still round-trips correctly.
const storedInternal = (existing as { isInternal?: unknown } | undefined)?.isInternal;
const internalOverride = typeof storedInternal === 'boolean' ? storedInternal : undefined;

const receipt = executeDomainCommand(
editor,
() => {
const didReopen = reopenComment({
commentId: identity.commentId,
importedId: identity.importedId,
internal: internalOverride,
});
if (didReopen) {
// Clear the resolved markers in the entity store so subsequent
// `comments.list()` reflects the reopen. `resolvedTime` is
// dropped explicitly because `upsertCommentEntity` merges
// partials and would otherwise leave the prior timestamp in
// place.
upsertCommentEntity(store, identity.commentId, {
importedId: identity.importedId,
isDone: false,
resolvedTime: null,
});
}
return Boolean(didReopen);
},
{ expectedRevision: options?.expectedRevision },
);

if (receipt.steps[0]?.effect !== 'changed') {
return {
success: false,
failure: { code: 'NO_OP', message: 'Comment reopen produced no change.' },
};
}

return { success: true, updated: [toCommentAddress(identity.commentId)] };
}

function removeCommentHandler(editor: Editor, input: RemoveCommentInput, options?: RevisionGuardOptions): Receipt {
const removeComment = requireEditorCommand(editor.commands?.removeComment, 'comments.remove (removeComment)');

Expand Down Expand Up @@ -986,6 +1049,7 @@ export function createCommentsWrapper(editor: Editor): CommentsAdapter {
move: (input: MoveCommentInput, options?: RevisionGuardOptions) => moveCommentHandler(editor, input, options),
resolve: (input: ResolveCommentInput, options?: RevisionGuardOptions) =>
resolveCommentHandler(editor, input, options),
reopen: (input: ReopenCommentInput, options?: RevisionGuardOptions) => reopenCommentHandler(editor, input, options),
remove: (input: RemoveCommentInput, options?: RevisionGuardOptions) => removeCommentHandler(editor, input, options),
setInternal: (input: SetCommentInternalInput, options?: RevisionGuardOptions) =>
setCommentInternalHandler(editor, input, options),
Expand Down
Loading
Loading