feat: conversations on a ticket are settled, and you see who is typing

A top-level comment and its replies can be resolved by whoever can
change the ticket (CommentThreadResolvedEvent), with an optional
conclusion added as a reply marked as a decision, so it shows above the
ticket. A settled conversation folds to one line (topic, who settled it
and the conclusion) and can be unfolded or reopened; the header counts
the open ones, and finishing the ticket with one open asks first.

«Ana está escribiendo…» shows in the conversation being answered, at
once: a `typing` mutation publishes to a Redis channel per ticket
(`typing` subscription), never stored. In replies, Enter sends and
Shift+Enter breaks the line, except inside lists and code.

API: resolveThread / PUT-DELETE /api/comments/{id}/resolution, Comment
.resolved/resolvedBy/resolvedAt, typing mutation and subscription. MCP:
resolve_thread.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 22:35:46 +02:00
parent c0b0417b6d
commit 199b60e4ce
51 changed files with 1965 additions and 126 deletions

View File

@@ -1050,6 +1050,27 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/comments/{commentId}/resolution": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
/**
* Settle a conversation (a top-level comment and its replies)
* @description Whoever can change the ticket. A conclusion is added as a reply marked as a decision. The web folds a settled conversation.
*/
put: operations["CommentsController_resolveThread"];
post?: never;
/** Reopen a conversation */
delete: operations["CommentsController_reopenThread"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/me/releases": {
parameters: {
query?: never;
@@ -3080,6 +3101,12 @@ export interface components {
decidedBy: components["schemas"]["UserRefDto"] | null;
/** Format: date-time */
decidedAt: string | null;
/** @description A conversation (top-level comment and its replies) that was settled */
resolved: boolean;
/** @description Who settled it */
resolvedBy: components["schemas"]["UserRefDto"] | null;
/** Format: date-time */
resolvedAt: string | null;
/** Format: date-time */
createdAt: string;
/** Format: date-time */
@@ -3144,6 +3171,10 @@ export interface components {
/** @description true adds the reaction, false takes it back */
on: boolean;
};
ResolveThreadDto: {
/** @description What was settled: added as a reply marked as a decision before it closes */
conclusion?: string;
};
/** @enum {string} */
ReleaseStatus: "PLANNED" | "RELEASED" | "ARCHIVED";
ReleaseDto: {
@@ -8596,6 +8627,160 @@ export interface operations {
};
};
};
CommentsController_resolveThread: {
parameters: {
query?: never;
header?: never;
path: {
commentId: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ResolveThreadDto"];
};
};
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["CommandResultDto"];
};
};
/** @description Invalid input */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Missing or rejected Pireon token */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not a member of the project (or not allowed) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Someone changed it at the same time: retry */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Business rule (e.g. a transition the workflow forbids) */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
};
};
CommentsController_reopenThread: {
parameters: {
query?: never;
header?: never;
path: {
commentId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["CommandResultDto"];
};
};
/** @description Invalid input */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Missing or rejected Pireon token */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not a member of the project (or not allowed) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Someone changed it at the same time: retry */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Business rule (e.g. a transition the workflow forbids) */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
};
};
ReleasesController_upcoming: {
parameters: {
query?: {