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

@@ -59,6 +59,24 @@ export class CommentResolver {
return this.comments.markDecision(me.id, commentId, on);
}
@Mutation(() => CommandResultGql, {
description:
'Settle a conversation (a top-level comment and its replies) or reopen it (resolved: false). A conclusion is added as a reply marked as a decision. Whoever can change the ticket',
})
resolveThread(
@CurrentUser() me: User,
@Args('commentId', { type: () => ID }) commentId: string,
@Args('resolved', { defaultValue: true }) resolved: boolean,
@Args('conclusion', { nullable: true }) conclusion: string | null,
) {
return this.comments.resolveThread(
me.id,
commentId,
resolved,
conclusion ?? null,
);
}
@OptionalAuth()
@Query(() => [DecisionGql], {
description:

View File

@@ -77,6 +77,28 @@ export class IssueResolver {
return this.presence.touch({ id: me.id, name: me.name }, issueId, editing);
}
@Mutation(() => Boolean, {
description:
'I am writing on this ticket (send every few seconds while typing): those with it open see it at once via `typing`',
})
async typing(
@CurrentUser() me: User,
@Args('issueId', { type: () => ID }) issueId: string,
@Args('threadId', {
type: () => ID,
nullable: true,
description: 'The conversation being answered; null: a new comment',
})
threadId: string | null,
) {
await this.presence.typing(
{ id: me.id, name: me.name },
issueId,
threadId ?? null,
);
return true;
}
@Mutation(() => Boolean, { description: "I left this ticket's page" })
async leavePresence(
@CurrentUser() me: User,

View File

@@ -13,6 +13,7 @@ import {
ProjectChangeGql,
ReleaseChangeGql,
SprintChangeGql,
TypingGql,
} from './types';
const asIs = { resolve: <T>(payload: T) => payload };
@@ -48,6 +49,18 @@ export class SubscriptionResolver {
return this.changes.watchComments(me.id, issueId);
}
@Subscription(() => TypingGql, {
...asIs,
description:
"Who is writing on a ticket, as they type (one's own come too: leave them out)",
})
typing(
@CurrentUser() me: User,
@Args('issueId', { type: () => ID }) issueId: string,
) {
return this.changes.watchTyping(me.id, issueId);
}
@Subscription(() => ActivityGql, {
...asIs,
description: 'Live activity feed',

View File

@@ -562,6 +562,17 @@ export class CommentGql {
})
decidedBy!: UserRefGql | null;
@Field(() => String, { nullable: true }) decidedAt!: string | null;
@Field({
description:
'A conversation (top-level comment and its replies) that was settled',
})
resolved!: boolean;
@Field(() => UserRefGql, {
nullable: true,
description: 'Who settled it',
})
resolvedBy!: UserRefGql | null;
@Field(() => String, { nullable: true }) resolvedAt!: string | null;
@Field() createdAt!: string;
@Field(() => String, { nullable: true }) editedAt!: string | null;
@Field(() => Int) version!: number;
@@ -1157,6 +1168,20 @@ export class ProjectInsightsGql {
proposals!: ProposalsFunnelGql | null;
}
@ObjectType('Typing', {
description: 'Someone writing on a ticket right now (not stored)',
})
export class TypingGql {
@Field(() => ID) issueId!: string;
@Field(() => UserRefGql) user!: UserRefGql;
@Field(() => ID, {
nullable: true,
description: 'The conversation being answered; null: a new comment',
})
threadId!: string | null;
@Field() at!: string;
}
@ObjectType('Presence', {
description: 'Someone on a ticket right now (heartbeat in the last 30 s)',
})

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 })

View File

@@ -1552,6 +1552,18 @@ export class PushEndpointDto {
endpoint!: string;
}
export class ResolveThreadDto {
@ApiPropertyOptional({
description:
'What was settled: added as a reply marked as a decision before it closes',
maxLength: 20000,
})
@IsOptional()
@IsString()
@MaxLength(20000)
conclusion?: string;
}
// ---- Alerts → bugs ----------------------------------------------------------
export class AlertSettingsDto {

View File

@@ -642,6 +642,19 @@ export class CommentDto {
decidedBy!: UserRefDto | null;
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
decidedAt!: string | null;
@ApiProperty({
description:
'A conversation (top-level comment and its replies) that was settled',
})
resolved!: boolean;
@ApiProperty({
type: UserRefDto,
nullable: true,
description: 'Who settled it',
})
resolvedBy!: UserRefDto | null;
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
resolvedAt!: string | null;
@ApiProperty({ format: 'date-time' }) createdAt!: string;
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
editedAt!: string | null;