feat: replies to comments

A comment can answer another one (replyTo), one level deep: answering a
reply joins the same thread, and it must be on the same ticket. Whoever
wrote the comment gets a new REPLIED notification, and the activity says
"respondió a un comentario".

A comment deleted while it has replies stays, without its text
(deleted: true), so the thread still reads; it goes with its last reply
and does not count in commentCount.

The ticket page shows threads with their replies indented and a
"Responder" box; REST, GraphQL and the MCP (add_comment, get_issue) take
and show replyTo. Existing comments are unchanged. Docs, Word and PDF
updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 11:14:37 +02:00
parent c2928d2385
commit ea4ef82239
44 changed files with 517 additions and 60 deletions

View File

@@ -531,3 +531,32 @@ a.ts-key {
border-radius: 0.5rem;
background: color-mix(in srgb, var(--ts-c-red, #dc2626) 12%, transparent);
}
// One level of replies under each comment.
.ts-replies {
display: grid;
gap: 0.75rem;
margin: -0.25rem 0 0.5rem 2.75rem;
padding-left: 0.875rem;
border-left: 2px solid var(--ts-line);
&:empty {
display: none;
}
}
.ts-comment__gone {
margin: 0;
font-style: italic;
}
.ts-reply__open {
justify-self: start;
font-size: 0.8125rem;
}
.ts-reply__actions {
display: flex;
gap: 0.75rem;
align-items: center;
}

View File

@@ -1,3 +1,4 @@
import { NgTemplateOutlet } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
@@ -71,6 +72,7 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
selector: 'ts-issue-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
NgTemplateOutlet,
RouterLink,
FormField,
AttachmentItem,
@@ -512,22 +514,24 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
<article class="ts-block">
<h2 class="ts-section">
Comentarios
<span class="ts-mono">{{ facade.comments().length }}</span>
<span class="ts-mono">{{ commentCount() }}</span>
</h2>
@for (comment of facade.comments(); track comment.id) {
<ng-template #commentView let-comment>
<div class="ts-comment">
<span class="ts-comment__avatar" aria-hidden="true">{{
initialsOf(comment.author.name)
}}</span>
<div class="ts-comment__body">
<div class="ts-comment__head">
<strong>{{ comment.author.name }}</strong>
<strong>{{
comment.deleted ? '—' : comment.author.name
}}</strong>
<span class="ts-comment__when"
>{{ ago(comment.createdAt)
}}{{ comment.editedAt ? ' · editado' : '' }}</span
>
<span class="ts-spacer"></span>
@if (comment.author.id === facade.me()) {
@if (!comment.deleted && comment.author.id === facade.me()) {
<pi-icon-button
icon="edit"
color="grey"
@@ -537,7 +541,8 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
/>
}
@if (
comment.author.id === facade.me() || project.canManage()
!comment.deleted &&
(comment.author.id === facade.me() || project.canManage())
) {
<pi-icon-button
icon="trash"
@@ -548,6 +553,9 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
/>
}
</div>
@if (comment.deleted) {
<p class="ts-muted ts-comment__gone">Comentario borrado</p>
} @else {
<p class="ts-prewrap">
@for (part of segments(comment.body); track $index) {
@if ('mention' in part) {
@@ -561,7 +569,8 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
}
}
</p>
@if (filesOf(comment).length) {
}
@if (!comment.deleted && filesOf(comment).length) {
<div class="ts-attachments">
@for (file of filesOf(comment); track file.id) {
<ts-attachment
@@ -573,6 +582,57 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
}
</div>
</div>
</ng-template>
@for (thread of threads(); track thread.comment.id) {
<ng-container
*ngTemplateOutlet="commentView; context: { $implicit: thread.comment }"
/>
<div class="ts-replies">
@for (reply of thread.replies; track reply.id) {
<ng-container
*ngTemplateOutlet="commentView; context: { $implicit: reply }"
/>
}
@if (replyingTo() === thread.comment.id) {
<form
class="ts-form ts-reply"
(submit)="$event.preventDefault(); reply(thread.comment)"
novalidate
>
<pi-textarea
[placeholder]="'Responde a ' + thread.comment.author.name + '…'"
[(value)]="replyBody"
/>
<div class="ts-reply__actions">
<pi-action-button
color="primary"
text="Responder"
[disabled]="!replyBody().trim()"
(execute)="reply(thread.comment)"
/>
<button
type="button"
class="ts-plain-link"
(click)="replyingTo.set(null)"
>
Cancelar
</button>
</div>
</form>
} @else if (project.canContribute() && !thread.comment.deleted) {
<button
type="button"
class="ts-plain-link ts-reply__open"
(click)="startReply(thread.comment)"
>
Responder{{
thread.replies.length
? ' (' + thread.replies.length + ')'
: ''
}}
</button>
}
</div>
}
@if (project.canContribute()) {
<form
@@ -934,6 +994,25 @@ export class IssuePage {
protected readonly linkType = signal<string | null>(IssueLinkType.RELATES_TO);
protected readonly linkTarget = signal('');
protected readonly commentBody = signal('');
/** The comment being answered, and the answer being written. */
protected readonly replyingTo = signal<string | null>(null);
protected readonly replyBody = signal('');
/** Top-level comments, each with its replies (oldest first). */
protected readonly threads = computed(() => {
const comments = this.facade.comments();
const ids = new Set(comments.map((c) => c.id));
const replies = new Map<string, Comment[]>();
for (const c of comments)
if (c.replyTo && ids.has(c.replyTo))
replies.set(c.replyTo, [...(replies.get(c.replyTo) ?? []), c]);
// A reply whose comment did not load stands on its own.
return comments
.filter((c) => !c.replyTo || !ids.has(c.replyTo))
.map((comment) => ({ comment, replies: replies.get(comment.id) ?? [] }));
});
protected readonly commentCount = computed(
() => this.facade.comments().filter((c) => !c.deleted).length,
);
protected readonly commentFiles = signal<File[]>([]);
protected readonly segments = mentionSegments;
/** Members matching what is typed after an `@` at the end of the comment. */
@@ -1126,6 +1205,19 @@ export class IssuePage {
this.commentFiles.set([]);
}
protected startReply(comment: Comment): void {
this.replyBody.set('');
this.replyingTo.set(comment.id);
}
protected reply(comment: Comment): void {
const body = this.replyBody().trim();
if (!body) return;
this.facade.addComment(body, [], comment.id);
this.replyBody.set('');
this.replyingTo.set(null);
}
protected mention(user: { id: string; name: string }): void {
this.commentBody.update((text) => insertMention(text, user));
}