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

@@ -17,7 +17,11 @@ import {
import { ApiCommandErrors } from '@task-stream/api/shared/presenters';
import { CommentsService } from '@task-stream/api/tracker/application';
import { REACTIONS } from '@task-stream/api/tracker/domain';
import { CommentBodyDto, ReactToCommentDto } from './dto/requests.dto';
import {
CommentBodyDto,
ReactToCommentDto,
ResolveThreadDto,
} from './dto/requests.dto';
import { CommandResultDto } from './dto/responses.dto';
/** REST adapter of the comment use cases (listing and adding live under /issues). */
@@ -78,6 +82,35 @@ export class CommentsController {
return this.comments.markDecision(me.id, commentId, false);
}
@Put(':commentId/resolution')
@ApiOperation({
summary: '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.',
})
@ApiOkResponse({ type: CommandResultDto })
@ApiCommandErrors()
resolveThread(
@CurrentUser() me: User,
@Param('commentId') commentId: string,
@Body() body: ResolveThreadDto,
) {
return this.comments.resolveThread(
me.id,
commentId,
true,
body.conclusion ?? null,
);
}
@Delete(':commentId/resolution')
@ApiOperation({ summary: 'Reopen a conversation' })
@ApiOkResponse({ type: CommandResultDto })
@ApiCommandErrors()
reopenThread(@CurrentUser() me: User, @Param('commentId') commentId: string) {
return this.comments.resolveThread(me.id, commentId, false);
}
@Delete(':commentId')
@ApiOperation({ summary: 'Delete a comment (author or project admin)' })
@ApiOkResponse({ type: CommandResultDto })